Node.js Express image API: validating text prompts and returning Base64 or signed URLs
Short answer: put prompt validation and response-shape decisions at the Express boundary, then hide generation and storage behind interfaces you can evaluate independently. Return Base64 for small, one-shot evaluation results; return a short-lived signed URL for assets that a browser or product will read again. Measure retries, delivery, and image quality before choosing a default.
The interesting part is not making an HTTP request to an image model. It is keeping an apparently successful request from creating an ambiguous, expensive, or publicly readable result.
Keep it boring.
One request. One identity.
No hidden retries.
How should an Express image endpoint validate prompts and deliver generated media?
Treat the route as a contract, not as a thin pass-through. Parse JSON, require prompt to be a string, trim it, reject an empty value, and apply a documented application limit. Validate response_mode against an allowlist such as base64 and signed_url; decide whether unknown fields are rejected before clients depend on accidental behavior. A schema library can enforce the same rules in a Node.js service. The important boundary is that the generation adapter receives a normalized command.
Use distinct status classes for malformed input and downstream work. A 400 can represent a wrong type, blank prompt, or unsupported mode. A 413 is appropriate only when the application deliberately treats a payload as too large. These are contract choices, not universal model limits. Return a stable error code and request ID, while keeping raw upstream bodies and complete prompts out of the response.
The success envelope should make the two delivery paths explicit. Base64 carries image_base64 and media_type. A stored result carries an opaque object_id, signed_url, and media_type. Never return a storage credential, and do not mistake an object ID for a public URL.
Here is a small Python contract sketch. The functions are injected so tests can replace a model adapter and private object store without coupling the endpoint to a particular SDK.
import base64
import uuid
from dataclasses import dataclass
from typing import Callable, Literal
ResponseMode = Literal["base64", "signed_url"]
@dataclass(frozen=True)
class ImageCommand:
prompt: str
response_mode: ResponseMode
def parse_command(body: dict) -> ImageCommand:
value = body.get("prompt")
if not isinstance(value, str):
raise ValueError("prompt must be a string")
prompt = value.strip()
if not prompt:
raise ValueError("prompt must contain text")
if len(prompt) > 2_000:
raise ValueError("prompt exceeds the application limit")
mode = body.get("response_mode", "signed_url")
if mode not in ("base64", "signed_url"):
raise ValueError("response_mode must be base64 or signed_url")
return ImageCommand(prompt, mode)
def make_response(
body: dict,
generate: Callable[[str], tuple[bytes, str]],
put_private: Callable[[str, bytes, str], None],
sign_read: Callable[[str, int], str],
) -> dict:
command = parse_command(body)
request_id = str(uuid.uuid4())
image_bytes, media_type = generate(command.prompt)
if command.response_mode == "base64":
return {
"id": request_id,
"media_type": media_type,
"image_base64": base64.b64encode(image_bytes).decode("ascii"),
}
object_id = f"generated/{request_id}"
put_private(object_id, image_bytes, media_type)
return {
"id": request_id,
"media_type": media_type,
"object_id": object_id,
"signed_url": sign_read(object_id, 300),
}
The 2,000-character limit and 300-second expiry are example policy values, not claims about a model or storage service. Make them configuration, document why they exist, and test the boundary values.
Base64 or a signed URL: what changes for the client and the system?
Base64 is convenient in a notebook-to-prod loop: one JSON response is self-contained, and an eval runner can decode it without a storage fixture. The trade-off is structural. Binary data becomes larger JSON that passes through proxy limits, logs, retry buffers, and client memory. It is a reasonable choice for a small disposable result that will be consumed once.
A signed URL adds a fetch, but keeps bytes out of the JSON envelope and lets private storage handle retention and access policy. That fits galleries, chat history, and clients that may read the same image more than once. Signing is authorization for a particular read; it does not define deletion, tenant isolation, or audit retention. The object can outlive the URL, so those policies still need an owner.
| Mode | Fits | Main cost | Contract check |
|---|---|---|---|
| Base64 | One-shot evals and small outputs | Larger JSON and memory pressure | Decode bytes and verify declared media type |
| Signed URL | Repeat reads and retained assets | Storage, expiry, and a second request | Fetch before expiry; verify access after expiry |
| Async job | Slow or batch-oriented generation | Polling or callback state | Assert stable state transitions and result identity |
Do not conflate delivery mode with execution mode. A synchronous request can store an image, and an asynchronous job can eventually return Base64 or a URL. Batch APIs are useful for offline evaluation sets, but they introduce a completion lifecycle that should be visible in the public contract.
Where do retries, idempotency, and private storage fail?
A timeout does not tell the client whether generation started. Retrying the whole Express handler can therefore duplicate generation or storage. Accept an idempotency key, create a request record before durable side effects, and bind every attempt and object to that record. A replay with the same normalized request returns the recorded state. Reusing the key with different input is a collision and should be rejected. This is application behavior; it is not a promise that an arbitrary image API executes exactly once.
Keep retries narrow. Do not retry validation failures. Retry only documented transient classes, cap attempts, add jitter, and preserve the same request identity. Stable error envelopes should distinguish request rejection, generation failure, storage failure, and delivery failure. One generic image_failed metric cannot tell an evaluator which boundary needs attention.
Private storage deserves tests of its own: anonymous reads must fail, authorized reads must work before expiry, and expired URLs must stop granting access. Avoid logging the full signed query string. A prompt fingerprint can connect an operational event to an eval case without putting sensitive prompt text in every log. Your mileage may vary with the threat model; validate the actual browser, proxy, and logging path.
What should an eval harness measure before production?
A valid PNG is not evidence that the endpoint met the user's intent. Build a fixed prompt set containing ordinary requests, misspellings, text-in-image cases, and policy-edge inputs relevant to the product. Run it through the public request contract, not a shortcut around validation. Record the normalized prompt or a controlled fingerprint, response mode, media type, byte count, latency, retry count, terminal outcome, and object-retention result.
The quality score can be human or task-specific, but it needs a repeatable rubric. For a notebook-to-prod handoff, I want to see the same prompt set across adapter changes, with failures grouped by boundary. I also keep a small replay file for every rejected case: the normalized request, the selected response mode, the expected access result, and the reason the evaluator marked it down. When a change later improves visual quality but increases transfer size or retry frequency, that replay makes the trade-off visible instead of turning it into a debate over a handful of screenshots. The file is deliberately boring and versioned with the contract tests, so a new adapter cannot quietly change what a passing case means. A response that looks right while leaving two stored objects behind is still a failed operation.
Cost belongs in the same experiment. Count generation attempts, retries, image dimensions, transferred bytes, and retained objects. A single provider price cannot explain a doubled bill caused by a retry loop or a retention rule that never deletes. Prompt-cost awareness means measuring the behavior your endpoint creates, not just comparing a line item.
This design is not suitable when callers need progressive image updates, when residency rules exclude the available generation region, or when the team must run models entirely on hardware it controls. Use a streaming contract for progressive output, a region-compatible deployment for residency, or a self-hosted runtime when operational control outweighs managed-service convenience. Choose the response mode only after those constraints and the eval results are visible.
Top comments (0)