History

feat: add HTTP caching for unauthenticated OpenAPI spec inspection

Cache spec fetch responses when no custom headers are forwarded, using public s-maxage=3600 with stale-while-revalidate. Requests with auth headers bypass the cache to avoid leaking credentials.

Rhys Sullivan committed 8 months ago

Commit ff126951 parent 8adfbf21 file · +27 −16Browse files

Unified · Split

executor/apps/web/src/app/api/openapi/inspect/route.ts+27−16
@@ -24,11 +24,18 @@ const blockedForwardedHeaderNames = new Set([ · 23 unmodified lines
2424 "referer",
2525 ]);
2626
27−function noStoreJson(payload: unknown, status: number): Response {
27+function jsonResponse(
28+ payload: unknown,
29+ status: number,
30+ options?: { cacheable?: boolean },
31+): Response {
32+ const cacheable = options?.cacheable === true;
2833 return Response.json(payload, {
2934 status,
3035 headers: {
31− "cache-control": "no-store",
36+ "cache-control": cacheable
37+ ? "public, s-maxage=3600, stale-while-revalidate=86400"
38+ : "no-store",
3239 },
3340 });
3441 }
@@ -142,14 +149,14 @@ function extractErrorDetail(text: string): string | undefined { · 107 unmodified lines
142149 export async function POST(request: Request): Promise<Response> {
143150 const parsedRequest = requestSchema.safeParse(await request.json().catch(() => null));
144151 if (!parsedRequest.success) {
145− return noStoreJson({ detail: "Invalid OpenAPI inspection request" }, 400);
152+ return jsonResponse({ detail: "Invalid OpenAPI inspection request" }, 400);
146153 }
147154
148155 let parsedUrl: URL;
149156 try {
150157 parsedUrl = new URL(parsedRequest.data.specUrl);
151158 } catch {
152− return noStoreJson({ detail: "Spec URL is invalid" }, 400);
159+ return jsonResponse({ detail: "Spec URL is invalid" }, 400);
153160 }
154161
155162 const protocol = parsedUrl.protocol.toLowerCase();
@@ -157,25 +164,28 @@ export async function POST(request: Request): Promise<Response> { · 1 unmodified line
157164 const localAllowed = process.env.NODE_ENV !== "production";
158165
159166 if (parsedUrl.username || parsedUrl.password) {
160− return noStoreJson({ detail: "Credentials in spec URL are not allowed" }, 400);
167+ return jsonResponse({ detail: "Credentials in spec URL are not allowed" }, 400);
161168 }
162169
163170 if (protocol !== "https:" && protocol !== "http:") {
164− return noStoreJson({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400);
171+ return jsonResponse({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400);
165172 }
166173
167174 if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) {
168− return noStoreJson({ detail: "Spec URL must use https://" }, 400);
175+ return jsonResponse({ detail: "Spec URL must use https://" }, 400);
169176 }
170177
171178 if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) {
172− return noStoreJson({ detail: "Private or local hosts are not allowed" }, 400);
179+ return jsonResponse({ detail: "Private or local hosts are not allowed" }, 400);
173180 }
174181
175182 if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) {
176− return noStoreJson({ detail: "Spec host must be a public DNS host" }, 400);
183+ return jsonResponse({ detail: "Spec host must be a public DNS host" }, 400);
177184 }
178185
186+ const forwardedHeaders = sanitizeForwardHeaders(parsedRequest.data.headers);
187+ const cacheableRequest = Object.keys(forwardedHeaders).length === 0;
188+
179189 const controller = new AbortController();
180190 const timeoutId = setTimeout(() => controller.abort(), INSPECTION_TIMEOUT_MS);
181191
@@ -184,15 +194,16 @@ export async function POST(request: Request): Promise<Response> { · 2 unmodified lines
184194 method: "GET",
185195 headers: {
186196 Accept: "application/json, application/yaml, text/yaml, text/plain;q=0.9, */*;q=0.8",
187− ...sanitizeForwardHeaders(parsedRequest.data.headers),
197+ ...forwardedHeaders,
188198 },
189199 signal: controller.signal,
190− cache: "no-store",
200+ cache: cacheableRequest ? "force-cache" : "no-store",
201+ ...(cacheableRequest ? { next: { revalidate: 3600 } } : {}),
191202 });
192203
193204 if (!response.ok) {
194205 const detail = extractErrorDetail(await response.text().catch(() => ""));
195− return noStoreJson({
206+ return jsonResponse({
196207 status: response.status,
197208 statusText: response.statusText,
198209 detail,
@@ -207,19 +218,19 @@ export async function POST(request: Request): Promise<Response> { · 8 unmodified lines
207218 contentType,
208219 });
209220
210− return noStoreJson({
221+ return jsonResponse({
211222 status: response.status,
212223 statusText: response.statusText,
213224 spec: inspected.spec,
214225 inferredAuth: inspected.inferredAuth,
215− }, 200);
226+ }, 200, { cacheable: cacheableRequest });
216227 } catch (error) {
217228 if (isAbortError(error)) {
218− return noStoreJson({ detail: "Request timed out while fetching spec" }, 504);
229+ return jsonResponse({ detail: "Request timed out while fetching spec" }, 504);
219230 }
220231
221232 const detail = error instanceof Error ? error.message : "Failed to fetch spec";
222− return noStoreJson({ detail }, 502);
233+ return jsonResponse({ detail }, 502);
223234 } finally {
224235 clearTimeout(timeoutId);
225236 }