DEV Community

KellanRhodes1542
KellanRhodes1542

Posted on

Catalog Image Pipeline: Node.js Express Prompt Validation and Signed URL Delivery

Short answer: for marketplace catalog enrichment, accept a narrow product record, build the image prompt on the server, validate the generated asset metadata, and return a signed URL by default; reserve base64 for callers that truly need one self-contained response.

Choice Default Use the alternative when
Delivery Signed URL A caller cannot perform a second fetch
Prompt input Structured catalog fields A trusted internal tool needs a reviewed free-form prompt
Work mode One request, one asset A catalog import is already asynchronous
Integration Small TypeScript adapter A disposable prototype has no second caller

Recommendation: optimize for a correct link between listing, prompt version, and image asset. Transport comes second. This is the revenue-per-hour choice for a one-person SaaS: own the catalog contract, outsource undifferentiated generation and storage, and keep enough separation to ship weekly.

Catalog input and prompt validation

Don't let the public request mirror a model provider's option bag. A marketplace description is messy input: it may mix product dimensions, shipping promises, seller notes, and visual attributes. Two listings can even share the description "navy travel bag" while one record is a 20-liter backpack and the other is a 45-liter duffel. If the prompt builder uses only description text, both operations become indistinguishable at the boundary.

Keep it narrow.

For a first version, listingId, description, category, and responseMode are enough. Trim strings. Reject an empty identifier or description. Put explicit application limits on body size and field length, but treat those values as configuration because the supplied sources do not establish universal limits for image systems. An enum is better than a Boolean for response mode because signed_url and base64 remain legible in logs and client types.

Validation has two boundaries. Input validation asks whether the caller supplied a catalog operation the application understands. Output validation asks whether the image adapter returned the variant promised by that operation. The second check is easy to skip, yet it protects the importer from saving a URL response as inline data, accepting an unexpected media type, or attaching an asset to the wrong listing. In practical terms, structured output correctness means every successful response carries the application-owned requestId, the original listingId, an assetId, a MIME type, a prompt version, and exactly one delivery field. If any of those associations is missing, the request hasn't produced a usable catalog result even if an image was generated.

Use 400 for malformed JSON, 415 for a media type the endpoint does not accept, and 422 for a well-formed body that violates the application schema. More important than the exact taxonomy is consistency: write contract tests for it and don't invoke image generation in those tests.

What should a Node.js Express text-to-image endpoint preserve after prompt validation?

Image quality is visible immediately. Association errors are quieter. A plausible image attached to the wrong product can pass a casual review, then move through search indexes, feeds, and caches with convincing but incorrect metadata. The application should therefore allocate its own identifiers before generation begins and preserve the normalized input plus prompt-builder version beside the asset record.

A better builder includes category and selected visible attributes in a deterministic template, while excluding fulfillment notes and seller contact text. The system can then hash that normalized source, record prompt version catalog-v1, and verify that the response still names the listing that initiated the operation. This is not a claim that a particular prompt produces a particular picture. It is a bookkeeping rule that prevents an attractive output from becoming the wrong catalog fact.

The image adapter should know how to request an image and return bytes or an application-approved storage reference. It should not decide which listing owns the result. The route should orchestrate the operation, not contain provider-specific parsing. The catalog service owns association and versioning. Storage owns object lifecycle and signing. Those boundaries leave each failure observable without turning a route handler into the entire system.

Boring boundaries ship.

I'm not sure one prompt schema will cover every marketplace category. Furniture needs dimensions and material; apparel may need garment type and color. Resolve that uncertainty with category fixtures and review results before widening the public contract. A generic options object looks faster, but it pushes the hard decision into every caller and makes old catalog runs difficult to reproduce.

The TypeScript route and its adapter boundary

The following Express factory receives its route path and image service as dependencies. That matters here: a product's own endpoint path is an application decision, while an upstream service path must come from that service's verified discovery or documentation. The example invents neither.

import express, { type NextFunction, type Request, type Response } from "express";
import { randomUUID } from "node:crypto";

type ResponseMode = "signed_url" | "base64";

type ImageResult =
  | {
      mode: "signed_url";
      assetId: string;
      mimeType: "image/png" | "image/jpeg" | "image/webp";
      url: string;
    }
  | {
      mode: "base64";
      assetId: string;
      mimeType: "image/png" | "image/jpeg" | "image/webp";
      base64: string;
    };

interface ImageService {
  generate(input: {
    requestId: string;
    listingId: string;
    prompt: string;
    responseMode: ResponseMode;
    signal: AbortSignal;
  }): Promise<ImageResult>;
}

interface CatalogImageRequest {
  listingId: string;
  description: string;
  category: string;
  responseMode: ResponseMode;
}

class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
  ) {
    super(message);
  }
}

function parseRequest(body: unknown): CatalogImageRequest {
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
    throw new ApiError(422, "INVALID_BODY", "Body must be a JSON object");
  }

  const value = body as Record<string, unknown>;
  const listingId = typeof value.listingId === "string" ? value.listingId.trim() : "";
  const description = typeof value.description === "string" ? value.description.trim() : "";
  const category = typeof value.category === "string" ? value.category.trim() : "";
  const responseMode = value.responseMode ?? "signed_url";

  if (!listingId || !description || !category) {
    throw new ApiError(
      422,
      "INVALID_CATALOG_INPUT",
      "listingId, description, and category are required",
    );
  }
  if (responseMode !== "signed_url" && responseMode !== "base64") {
    throw new ApiError(
      422,
      "INVALID_RESPONSE_MODE",
      "responseMode must be signed_url or base64",
    );
  }

  return { listingId, description, category, responseMode };
}

function buildPrompt(input: CatalogImageRequest): string {
  return [
    `Product category: ${input.category}`,
    `Visible product description: ${input.description}`,
    "Create one catalog image with a plain background.",
  ].join("\n");
}

export function createCatalogImageApp(config: {
  imageGenerationPath: string;
  imageService: ImageService;
  requestTimeoutMs: number;
}) {
  const app = express();
  app.use(express.json({ limit: "32kb" }));

  app.post(
    config.imageGenerationPath,
    async (req: Request, res: Response, next: NextFunction) => {
      const requestId = randomUUID();
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);

      try {
        const input = parseRequest(req.body);
        const result = await config.imageService.generate({
          requestId,
          listingId: input.listingId,
          prompt: buildPrompt(input),
          responseMode: input.responseMode,
          signal: controller.signal,
        });

        if (result.mode !== input.responseMode) {
          throw new ApiError(502, "INVALID_RESULT_MODE", "Unexpected image result mode");
        }

        res.status(201).json({
          requestId,
          listingId: input.listingId,
          promptVersion: "catalog-v1",
          ...result,
        });
      } catch (error) {
        next(error);
      } finally {
        clearTimeout(timer);
      }
    },
  );

  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
    if (error instanceof ApiError) {
      res.status(error.status).json({
        error: { code: error.code, message: error.message },
      });
      return;
    }

    res.status(500).json({
      error: { code: "IMAGE_REQUEST_FAILED", message: "Image request failed" },
    });
  });

  return app;
}
Enter fullscreen mode Exit fullscreen mode

The adapter implementation is deliberately absent. Its model identifier, authentication, request fields, and upstream path must come from the selected service's current documentation; guessing them would make an otherwise useful Express example unsafe to copy. The same interface can wrap a hosted API, an internal worker, or a gateway without leaking that choice into catalog records.

Contract tests for catalog association

Test the route with a fake adapter. Cover malformed bodies, blank required fields, unsupported modes, both valid response variants, an adapter returning the wrong mode, and cancellation. Also assert that seller-only metadata never reaches buildPrompt. These tests are fast because they exercise the contract rather than a remote model.

Cost and delivery limits for signed URLs, base64, and batch work

A signed URL is the sensible default because the JSON control response stays separate from image bytes. Store the durable object key or assetId; treat the signed URL as a temporary access grant, not as the canonical product image identifier. Avoid logging the query string, since it carries access material.

Base64 wins when the consumer cannot make a second fetch or needs a self-contained message. The catch is larger JSON, more memory held by server and client, and a greater chance that payloads enter logs. Set explicit request and response limits, and test the full path through the actual proxy and consumer rather than assuming the Express limit is the only one involved.

Batch work is a separate decision. A full catalog import does not need to hold an interactive HTTP request open. Queue stable job records, keep idempotency at the catalog boundary, and reconcile results by application identifiers. The OpenAI Batch API guide is one public example of an asynchronous batch workflow, while LiteLLM is an open-source gateway example; neither changes the need for your own listing-to-asset contract.

Observe validation rejections, generation duration, storage duration, output byte count, delivery mode, and downstream fetch success. Log request and asset identifiers. Don't log raw prompts unless the marketplace's data policy permits seller-supplied descriptions to be retained there.

Measure before widening.

The runner-up should have a narrow job.

Choose base64 for a constrained internal consumer that needs one response and has measured payload limits. Choose asynchronous batch processing for scheduled enrichment where queue delay is acceptable and throughput matters more than interactive latency. Stick with a direct, provider-specific call only for a disposable experiment whose contract will not be consumed by another service.

A strict catalog schema is not suitable when trusted operators must explore provider controls before the product has decided which ones matter. In that phase, use a separate internal experiment surface. Do not quietly turn the production endpoint into a pass-through; once clients depend on arbitrary options, weekly shipping gets taxed by upstream changes and unrepeatable catalog state.

The final decision rule is plain: protect listing-to-asset correctness first, then choose the lightest delivery mode that fits the real consumer. A signed URL serves the common marketplace path. Base64 and batch are deliberate exceptions, not toggles added for completeness.

Further reading

Top comments (0)