diff --git a/.github/workflows/upload-openapi-spec.yml b/.github/workflows/upload-openapi-spec.yml index 167cc2e8..df18af00 100644 --- a/.github/workflows/upload-openapi-spec.yml +++ b/.github/workflows/upload-openapi-spec.yml @@ -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 }} diff --git a/apps/api/scripts/README.md b/apps/api/scripts/README.md index e6076d7a..80f391b8 100644 --- a/apps/api/scripts/README.md +++ b/apps/api/scripts/README.md @@ -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 diff --git a/apps/api/scripts/upload-openapi-spec.ts b/apps/api/scripts/upload-openapi-spec.ts index f51e7594..6b9718fe 100644 --- a/apps/api/scripts/upload-openapi-spec.ts +++ b/apps/api/scripts/upload-openapi-spec.ts @@ -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 { + 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; }