Files
classroomio/apps/api/scripts/upload-openapi-spec.ts
T
fe9f8c441f feat(api): public API plan gating, docs auth, and dashboard snippets (#701)
* fix(automation): align public API plan gating across cloud and self-hosted

Add shared canUsePublicApi helper so cloud allows Early Adopter and
Enterprise while self-hosted requires Enterprise. Update the API key
dashboard page and backend key creation checks to use the same rules.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(api): document bearer auth on public API reference

Add shared OpenAPI enrichment with bearerAuth security scheme and
authentication instructions. The interactive reference now loads an
enriched spec with Authorize support, and the API guide explains how
to create and use org API keys.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(dashboard): add API quick-start code snippets on automation page

Mirror the MCP client setup section with cURL, JavaScript, and Python
examples that show how to authenticate public API requests with org keys.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* ci(openapi): republish spec when public API OpenAPI helpers change

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(dashboard): add Go snippet to API quick-start examples

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(api): use /org/*/api and clarify dashboard key generation path

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(api): escape backticks in OpenAPI description template literal

Greptile flagged that unescaped backticks around * in
PUBLIC_API_OPENAPI_DESCRIPTION evaluated to NaN and dropped auth docs
from the Scalar spec. Also use plan-specific snackbar message on upgrade.

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>
2026-07-02 11:24:33 +01:00

214 lines
5.9 KiB
TypeScript

#!/usr/bin/env tsx
import 'dotenv/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { mkdirSync, writeFileSync } from 'fs';
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';
interface UploadOptions {
key: string;
content: string;
contentType?: string;
}
function filterPublicApiSpec(spec: Record<string, unknown>) {
const paths = (spec.paths ?? {}) as Record<string, unknown>;
const publicApiPaths = Object.fromEntries(
Object.entries(paths).filter(([path]) => path.startsWith('/public-api/v1/'))
);
const tags = Array.isArray(spec.tags)
? spec.tags.filter((tag) => {
if (!tag || typeof tag !== 'object' || !('name' in tag)) {
return false;
}
return typeof tag.name === 'string' && tag.name.startsWith('Public API');
})
: undefined;
return enrichPublicApiOpenApiSpec({
...spec,
openapi: spec.openapi ?? '3.1.0',
paths: publicApiPaths,
tags
});
}
class OpenAPISpecGenerator {
private s3Client: S3Client | null = null;
constructor() {
this.initializeS3Client();
}
private initializeS3Client() {
if (env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY && env.CLOUDFLARE_ACCOUNT_ID) {
this.s3Client = new S3Client({
region: 'auto',
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.CLOUDFLARE_ACCESS_KEY,
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY
}
});
console.log('✅ S3 client initialized for Cloudflare R2');
} else {
console.warn('⚠️ Cloudflare R2 credentials not found. Will only generate local spec file.');
}
}
async generateSpec(): Promise<string> {
try {
console.log('🔄 Generating OpenAPI specification...');
// Generate the OpenAPI spec from the Hono app
const spec = await generateSpecs(app, {
documentation: {
info: {
title: 'ClassroomIO API',
version: '1.0.0',
description: 'Manage your organization on classroomio via the API',
contact: {
name: 'ClassroomIO',
url: 'https://classroomio.com'
}
},
servers: [
{
url: 'https://api.classroomio.com',
description: 'Production environment'
},
{
url: 'http://localhost:3002',
description: 'Development environment'
}
]
}
});
const publicApiSpec = filterPublicApiSpec(spec as Record<string, unknown>);
const specString = JSON.stringify(publicApiSpec, null, 2);
console.log('✅ OpenAPI specification generated successfully');
return specString;
} catch (error) {
console.error('❌ Error generating OpenAPI spec:', error);
throw error;
}
}
async saveLocalSpec(spec: string): Promise<string> {
try {
const outputDir = join(process.cwd(), 'dist', 'openapi', 'public-api');
mkdirSync(outputDir, { recursive: true });
const filePath = join(outputDir, 'openapi.json');
writeFileSync(filePath, spec, 'utf8');
console.log(`✅ OpenAPI spec saved locally: ${filePath}`);
return filePath;
} catch (error) {
console.error('❌ Error saving local spec:', error);
throw error;
}
}
async uploadToR2(options: UploadOptions): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
}
try {
console.log(`🔄 Uploading to R2 bucket`);
const command = new PutObjectCommand({
Bucket: 'api',
Key: options.key,
Body: options.content,
ContentType: options.contentType || 'application/json',
CacheControl: 'public, max-age=3600' // Cache for 1 hour
});
await this.s3Client.send(command);
console.log(`✅ Successfully uploaded to R2`);
} catch (error) {
console.error('❌ Error uploading to R2:', error);
throw error;
}
}
async generateAndUpload(): Promise<void> {
try {
const spec = await this.generateSpec();
await this.saveLocalSpec(spec);
if (this.s3Client) {
const uploadKey = `openapi/public-api/openapi-${new Date().toISOString().split('T')[0]}.json`;
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'
});
}
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
} catch (error) {
console.error('❌ Failed to generate and upload OpenAPI spec:', error);
process.exit(1);
}
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: tsx scripts/upload-openapi-spec.ts
Examples:
tsx scripts/upload-openapi-spec.ts
Environment Variables Required for R2 Upload:
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
`);
return;
}
const generator = new OpenAPISpecGenerator();
await generator.generateAndUpload();
}
// Run the script if called directly
if (process.argv[1] && process.argv[1].endsWith('upload-openapi-spec.ts')) {
main()
.then(() => {
console.log('Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});
}
export { OpenAPISpecGenerator };