impress-2020/api/outfitImage.js

201 lines
7.5 KiB
JavaScript

/**
* /api/outfitImage returns an image of an outfit!
*
* Parameters:
* - size: Must be "150", "300", or "600", to indicate the image size you'd
* like back. (For example, "150" will return a 150x150 image.)
* - layerUrls: A comma-separated list of URLs to render, in order from
* bottom to top. This is a sorta "independent" render mode,
* not bound to any saved outfit. The URLs must match a known
* layer URL format. This mode will return a long-term cache
* header, so the client and our CDN cache can cache the
* requested URL forever. (NOTE: The Vercel cache seems pretty
* quick to eject them, though...)
* - id: Instead of `layerUrls`, you can instead provide an outfit ID, which
* will load the outfit data and render it directly. By default, this
* will return a 10-minute cache header, to keep individual users from
* re-loading the image from scratch too often, while still keeping it
* relatively fresh. (If you provide `updatedAt` too, we cache it for
* longer!)
* - updatedAt: If you provide an `id`, you may also provide `updatedAt`:
* the UNIX timestamp for when the outfit was last updated. This
* has no effect on image output, but it enables us to return a
* long-term cache header, so the client and our CDN cache can
* cache the requested URL forever. (NOTE: The Vercel cache
* seems pretty quick to eject them, though...)
*/
const beeline = require("honeycomb-beeline")({
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
dataset:
process.env["NODE_ENV"] === "production"
? "Dress to Impress (2020)"
: "Dress to Impress (2020, dev)",
serviceName: "impress-2020-gql-server",
sampleRate: 10,
});
import fetch from "node-fetch";
import gql from "graphql-tag";
import { print as graphqlPrint } from "graphql/language/printer";
import { renderOutfitImage } from "../src/server/outfit-images";
import getVisibleLayers, {
petAppearanceFragmentForGetVisibleLayers,
itemAppearanceFragmentForGetVisibleLayers,
} from "../src/shared/getVisibleLayers";
const VALID_LAYER_URLS = [
/^https:\/\/(impress-asset-images\.openneo\.net|impress-asset-images\.s3\.amazonaws\.com)\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/(150|300|600)x(150|300|600)\.png(\?[a-zA-Z0-9_-]+)?$/,
/^http:\/\/images\.neopets\.com\/cp\/(bio|object|items)\/data\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.(svg|png)(\?.*)?$/,
];
async function handle(req, res) {
const size = parseInt(req.query.size);
if (size !== 150 && size !== 300 && size !== 600) {
return reject(res, `Size must be 150, 300, or 600`);
}
let layerUrls;
let isSafeToCacheLongTerm;
if (req.query.layerUrls) {
layerUrls = req.query.layerUrls.split(",");
// When layerUrls are provided, it's always safe to cache long-term. We
// assume layer assets are immutable, and that TNT generally creates new
// IDs when they're not. (Or, if TNT's conversion strategy or our rendering
// strategy dramatically changes, we might add a cache-buster to the URL.)
isSafeToCacheLongTerm = true;
} else if (req.query.id) {
const outfitId = req.query.id;
try {
layerUrls = await loadLayerUrlsForSavedOutfit(outfitId, size);
} catch (e) {
console.error(e);
return reject(
res,
`Error loading data for outfit ${outfitId}: ${e.message}`,
500
);
}
// When an outfit ID is provided, it's only safe to cache long-term if
// `updatedAt` is also provided.
isSafeToCacheLongTerm = Boolean(req.query.updatedAt);
} else {
return reject(res, `Missing required parameter: layerUrls`);
}
for (const layerUrl of layerUrls) {
if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) {
return reject(res, `Unexpected layer URL format: ${layerUrl}`);
}
}
let imageResult;
try {
imageResult = await renderOutfitImage(layerUrls, size);
} catch (e) {
console.error(e);
return reject(res, `Error rendering image: ${e.message}`);
}
const { image, status } = imageResult;
if (status === "success" && isSafeToCacheLongTerm) {
// This image is safe to cache long-term, so send a long-term cache header!
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.status(200);
} else if (status === "success") {
// This image rendered successfully, but isn't safe to cache long-term. We
// cache for a short period of time, instead, to avoid thrashing too hard
// on individual users, while still keeping it relatively fresh.
res.setHeader("Cache-Control", "public, max-age=600, immutable");
res.status(200);
} else {
// On partial failure, we still send the image, but with a 500 status. We
// send a one-week cache header, but in such a way that the user can
// refresh the page to try again. (`private` means the CDN won't cache it,
// and we don't send `immutable`, which would save it even across reloads.)
// The 500 won't really affect the client, which will still show the image
// without feedback to the user - but it's a helpful debugging hint.
res.setHeader("Cache-Control", "private, max-age=604800");
res.status(500);
}
res.setHeader("Content-Type", "image/png");
return res.send(image);
}
const GRAPHQL_ENDPOINT = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/graphql`
: process.env.NODE_ENV === "development"
? "http://localhost:3000/api/graphql"
: "https://impress-2020.openneo.net/api/graphql";
// NOTE: Unlike in-app views, we only load PNGs here. We expect this to
// generally perform better, and be pretty reliable now that TNT is
// generating canonical PNGs for every layer!
const GRAPHQL_QUERY = gql`
query ApiOutfitImage($outfitId: ID!, $size: LayerImageSize) {
outfit(id: $outfitId) {
petAppearance {
layers {
imageUrl(size: $size)
}
...PetAppearanceForGetVisibleLayers
}
itemAppearances {
layers {
imageUrl(size: $size)
}
...ItemAppearanceForGetVisibleLayers
}
}
}
${petAppearanceFragmentForGetVisibleLayers}
${itemAppearanceFragmentForGetVisibleLayers}
`;
const GRAPHQL_QUERY_STRING = graphqlPrint(GRAPHQL_QUERY);
async function loadLayerUrlsForSavedOutfit(outfitId, size) {
const { errors, data } = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: GRAPHQL_QUERY_STRING,
variables: { outfitId, size: `SIZE_${size}` },
}),
}).then((res) => res.json());
if (errors && errors.length > 0) {
throw new Error(
`GraphQL Error: ${errors.map((e) => e.message).join(", ")}`
);
}
if (!data.outfit) {
throw new Error(`outfit ${outfitId} not found`);
}
const { petAppearance, itemAppearances } = data.outfit;
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
return visibleLayers
.sort((a, b) => a.depth - b.depth)
.map((layer) => layer.imageUrl);
}
function reject(res, message, status = 400) {
res.setHeader("Content-Type", "text/plain");
return res.status(status).send(message);
}
async function handleWithBeeline(req, res) {
beeline.withTrace(
{ name: "api/outfitImage", operation_name: "api/outfitImage" },
() => handle(req, res)
);
}
export default handleWithBeeline;