feat(api): purge Cloudflare CDN before and after OpenAPI spec upload (#704)
* feat(api): purge Cloudflare CDN before and after OpenAPI spec upload Purge api.cdn.clsrio.com edge cache for openapi-latest and the dated spec URL so deploys are not blocked by the 1h Cache-Control TTL on R2 objects. Uses the same CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID pattern as embed uploads; skips when credentials are unset. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(api): purge OpenAPI CDN cache only after upload Match the embeds upload pattern: update R2 first, then invalidate edge cache so clients refetch the new spec. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(api): treat Cloudflare purge 2xx as success when body is unparseable Avoid false upload failures when the purge API returns 200 with a non-JSON body. Addresses Greptile review on #704. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
ec61bfe7d9
commit
efdcfba68d
@@ -9,7 +9,65 @@ import { app } from '../src/app';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { generateSpecs } from 'hono-openapi';
|
||||
import { join } from 'path';
|
||||
import { enrichPublicApiOpenApiSpec } from '@cio/utils/openapi/public-api';
|
||||
import { enrichPublicApiOpenApiSpec, PUBLIC_API_OPENAPI_URL } from '@cio/utils/openapi/public-api';
|
||||
|
||||
const OPENAPI_CDN_BASE_URL = PUBLIC_API_OPENAPI_URL.replace(/\/openapi-latest\.json$/, '');
|
||||
|
||||
/** Cloudflare allows a limited number of URLs per purge request. */
|
||||
const PURGE_URL_BATCH = 30;
|
||||
|
||||
/**
|
||||
* Purge Cloudflare edge cache for public OpenAPI spec URLs.
|
||||
* R2 is origin storage; purging fixes stale responses when the hostname is proxied through Cloudflare.
|
||||
*/
|
||||
async function purgeCloudflareCdnForUrls(urls: string[]): Promise<void> {
|
||||
if (process.env.OPENAPI_SKIP_CDN_PURGE === '1' || process.env.OPENAPI_SKIP_CDN_PURGE === 'true') {
|
||||
console.log('Skipping Cloudflare CDN purge (OPENAPI_SKIP_CDN_PURGE is set).');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
|
||||
|
||||
if (!token || !zoneId) {
|
||||
console.log(
|
||||
'Skipping Cloudflare CDN cache purge. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to purge after upload.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueUrls = [...new Set(urls)];
|
||||
|
||||
for (let index = 0; index < uniqueUrls.length; index += PURGE_URL_BATCH) {
|
||||
const batch = uniqueUrls.slice(index, index + PURGE_URL_BATCH);
|
||||
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ files: batch })
|
||||
});
|
||||
|
||||
const payload: unknown = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cloudflare CDN purge failed: ${response.status} ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
console.log(`Purged Cloudflare CDN cache for ${batch.length} URL(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenApiCdnUrls(uploadKey: string): string[] {
|
||||
const datedFileName = uploadKey.split('/').pop()!;
|
||||
|
||||
return [PUBLIC_API_OPENAPI_URL, `${OPENAPI_CDN_BASE_URL}/${datedFileName}`];
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
key: string;
|
||||
@@ -152,18 +210,21 @@ class OpenAPISpecGenerator {
|
||||
|
||||
if (this.s3Client) {
|
||||
const uploadKey = `openapi/public-api/openapi-${new Date().toISOString().split('T')[0]}.json`;
|
||||
const cdnUrls = getOpenApiCdnUrls(uploadKey);
|
||||
|
||||
await this.uploadToR2({
|
||||
key: uploadKey,
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
// Also upload as latest version
|
||||
await this.uploadToR2({
|
||||
key: 'openapi/public-api/openapi-latest.json',
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
await purgeCloudflareCdnForUrls(cdnUrls);
|
||||
}
|
||||
|
||||
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
|
||||
@@ -189,6 +250,11 @@ Environment Variables Required for R2 Upload:
|
||||
CLOUDFLARE_ACCESS_KEY
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
|
||||
Optional — purge Cloudflare CDN cache for api.cdn.clsrio.com (after upload):
|
||||
CLOUDFLARE_API_TOKEN (Zone → Cache Purge → Purge)
|
||||
CLOUDFLARE_ZONE_ID
|
||||
OPENAPI_SKIP_CDN_PURGE=1 to skip purge
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user