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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
ec61bfe7d9
commit
efdcfba68d
@@ -66,5 +66,7 @@ jobs:
|
||||
CLOUDFLARE_ACCESS_KEY: ${{ secrets.CLOUDFLARE_ACCESS_KEY }}
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}
|
||||
|
||||
@@ -33,6 +33,14 @@ CLOUDFLARE_SECRET_ACCESS_KEY=your_secret_key
|
||||
CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
```
|
||||
|
||||
Optional — purge Cloudflare CDN cache for `api.cdn.clsrio.com` (after R2 upload):
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_API_TOKEN=your_api_token # Zone → Cache Purge → Purge
|
||||
CLOUDFLARE_ZONE_ID=your_zone_id
|
||||
OPENAPI_SKIP_CDN_PURGE=1 # skip purge when set
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The script will:
|
||||
@@ -43,6 +51,7 @@ The script will:
|
||||
4. **Upload to R2** (if credentials provided):
|
||||
- `openapi/public-api/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/public-api/openapi-latest.json` (latest version)
|
||||
5. **Purge Cloudflare CDN** (if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` are set) for both public URLs, after upload
|
||||
|
||||
## GitHub Action Integration
|
||||
|
||||
@@ -63,11 +72,16 @@ The GitHub Action runs on:
|
||||
- `CLOUDFLARE_SECRET_ACCESS_KEY`
|
||||
- `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
2. **Optional secrets** (CDN cache purge for `api.cdn.clsrio.com`):
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
- `CLOUDFLARE_ZONE_ID`
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Automatic OpenAPI spec generation from Hono routes
|
||||
- ✅ Local file generation for development
|
||||
- ✅ Cloudflare R2 upload with versioning
|
||||
- ✅ Optional Cloudflare CDN cache purge (after upload)
|
||||
- ✅ GitHub Action integration
|
||||
- ✅ Environment-specific deployments
|
||||
- ✅ PR previews and artifacts
|
||||
|
||||
@@ -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