feat(ci): add RAML 1.0 package generation pipeline
Build and Push Docker Image / build (push) Successful in 1m27s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Failing after 1m56s
CI / Tests & coverage (push) Failing after 1m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
Build and Push Docker Image / build (push) Successful in 1m27s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Failing after 1m56s
CI / Tests & coverage (push) Failing after 1m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
- Add generate:openapi, generate:raml, package:raml scripts to package.json - Add archiver dependency for creating tar.gz archives - Create scripts/generate-openapi.js to fetch merged OpenAPI spec from running server - Create scripts/package-raml.js to build versioned RAML tar.gz archive - Create .spectral.yml with minimal OpenAPI linting rules - Add /api/swagger.json endpoint to server/app.js for serving merged spec - Extend swagger job in ci.yml with RAML generation steps - Upload raml-package artifact to CI with 14-day retention
This commit is contained in:
@@ -85,3 +85,29 @@ jobs:
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Generate merged OpenAPI spec
|
||||
run: npm run generate:openapi
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
|
||||
- name: Convert to RAML
|
||||
run: npm run generate:raml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Package RAML artifact
|
||||
run: npm run package:raml
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
- name: Upload RAML package artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: raml-package
|
||||
path: dist/raml-*.tar.gz
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
extends: spectral:oas
|
||||
rules:
|
||||
# Ensure all operations have descriptions
|
||||
operation-description: warn
|
||||
# Ensure all paths have parameters defined
|
||||
path-params-defined: error
|
||||
# Ensure all schemas have examples where appropriate
|
||||
example-provided: warn
|
||||
# Disable rules that are too strict for this project
|
||||
operation-operationId: off
|
||||
Generated
+915
-2
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -13,7 +13,10 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
"audit:critical": "npm audit --audit-level=critical",
|
||||
"generate:openapi": "node scripts/generate-openapi.js",
|
||||
"generate:raml": "npx oas3-to-raml -i dist/openapi-merged.json -o dist/api.raml",
|
||||
"package:raml": "node scripts/package-raml.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -31,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@stoplight/spectral-cli": "^6.16.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"archiver": "^7.0.1",
|
||||
"concurrently": "^7.6.0",
|
||||
"nock": "^14.0.15",
|
||||
"nodemon": "^3.1.14",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Generates the merged OpenAPI spec by bootstrapping the Express app
|
||||
* and fetching the spec from /api/swagger.json.
|
||||
*
|
||||
* This ensures the generated spec matches exactly what users see in production.
|
||||
*/
|
||||
|
||||
const { createApp } = require('../server/app.js');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 34567; // Use a different port to avoid conflicts
|
||||
const OUTPUT_DIR = path.join(process.cwd(), 'dist');
|
||||
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'openapi-merged.json');
|
||||
|
||||
async function generateOpenApiSpec() {
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('Bootstrapping Express app in test mode...');
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on port ${PORT}`);
|
||||
|
||||
// Fetch the merged spec
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: PORT,
|
||||
path: '/api/swagger.json',
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const spec = JSON.parse(data);
|
||||
|
||||
// Validate it's a proper OpenAPI spec
|
||||
if (!spec.openapi || !spec.info) {
|
||||
throw new Error('Invalid OpenAPI spec: missing openapi or info field');
|
||||
}
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(spec, null, 2));
|
||||
console.log(`✓ OpenAPI spec written to ${OUTPUT_FILE}`);
|
||||
console.log(` Version: ${spec.openapi}`);
|
||||
console.log(` Title: ${spec.info.title}`);
|
||||
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing OpenAPI spec:', error.message);
|
||||
server.close(() => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error('Error fetching spec:', error.message);
|
||||
server.close(() => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${PORT} is already in use`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
generateOpenApiSpec()
|
||||
.then(() => {
|
||||
console.log('OpenAPI spec generation complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to generate OpenAPI spec:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generateOpenApiSpec };
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Creates a versioned tar.gz archive containing the RAML spec,
|
||||
* original OpenAPI spec, version metadata, and README.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist');
|
||||
const RAML_FILE = path.join(DIST_DIR, 'api.raml');
|
||||
const OPENAPI_FILE = path.join(DIST_DIR, 'openapi-merged.json');
|
||||
|
||||
function getVersion() {
|
||||
try {
|
||||
// Try to get the exact tag if we're on one
|
||||
const tag = execSync('git describe --tags --exact-match 2>/dev/null', { encoding: 'utf-8' }).trim();
|
||||
if (tag) return tag;
|
||||
} catch (e) {
|
||||
// Not on a tag, fall back to SHA
|
||||
}
|
||||
|
||||
try {
|
||||
// Get short commit SHA
|
||||
const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
||||
return sha;
|
||||
} catch (e) {
|
||||
// Not in a git repo, use timestamp
|
||||
return `dev-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitSha() {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function createVersionJson(version, commitSha) {
|
||||
return {
|
||||
version,
|
||||
commit: commitSha,
|
||||
generatedAt: new Date().toISOString(),
|
||||
tool: 'oas3-to-raml',
|
||||
openapiVersion: '3.1.0',
|
||||
ramlVersion: '1.0'
|
||||
};
|
||||
}
|
||||
|
||||
function createReadme(version, commitSha) {
|
||||
return `# sofarr RAML 1.0 Specification
|
||||
|
||||
## Origin
|
||||
|
||||
This RAML specification was automatically generated from the sofarr OpenAPI 3.1.0 specification.
|
||||
|
||||
- **Version:** ${version}
|
||||
- **Commit:** ${commitSha}
|
||||
- **Generated At:** ${new Date().toISOString()}
|
||||
- **Conversion Tool:** oas3-to-raml (npx)
|
||||
|
||||
## Contents
|
||||
|
||||
- \`api.raml\` - The RAML 1.0 specification
|
||||
- \`openapi-merged.json\` - Original merged OpenAPI 3.1.0 spec (for reference)
|
||||
- \`version.json\` - Metadata about this generation
|
||||
|
||||
## Known Limitations
|
||||
|
||||
This RAML spec was converted from OpenAPI 3.1.0. Some OpenAPI 3.1 features may not translate perfectly to RAML 1.0:
|
||||
|
||||
- Cookie-based authentication (CookieAuth) may require manual mapping to RAML security schemes
|
||||
- Advanced schema features (e.g., certain keywords, complex polymorphism) may be approximated or dropped
|
||||
- Webhook-specific features may not be fully represented
|
||||
|
||||
For the most accurate API documentation, refer to the live Swagger UI at \`/api/swagger\` or the original OpenAPI spec included in this archive.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Validate the RAML spec:
|
||||
\`\`\`bash
|
||||
npx raml-1-parser validate api.raml
|
||||
\`\`\`
|
||||
|
||||
2. Compare endpoints with the live Swagger UI at \`/api/swagger\`
|
||||
|
||||
3. Test in a RAML-aware tool (e.g., API Workbench, MuleSoft Anypoint)
|
||||
|
||||
## Quick Start
|
||||
|
||||
To use this RAML spec:
|
||||
|
||||
1. Extract the archive
|
||||
2. Open \`api.raml\` in your preferred RAML tool
|
||||
3. For development, import it into API Workbench or similar tools
|
||||
|
||||
## Source
|
||||
|
||||
This artifact was generated from the sofarr project:
|
||||
https://git.i3omb.com/Gandalf/sofarr
|
||||
|
||||
Generated from CI run on commit ${commitSha}.
|
||||
`;
|
||||
}
|
||||
|
||||
async function packageRaml() {
|
||||
const version = getVersion();
|
||||
const commitSha = getCommitSha();
|
||||
const archiveName = `raml-${version}`;
|
||||
const archivePath = path.join(DIST_DIR, `${archiveName}.tar.gz`);
|
||||
const stagingDir = path.join(DIST_DIR, archiveName);
|
||||
|
||||
console.log(`Packaging RAML for version: ${version}`);
|
||||
console.log(`Commit: ${commitSha}`);
|
||||
|
||||
// Check that required files exist
|
||||
if (!fs.existsSync(RAML_FILE)) {
|
||||
throw new Error(`RAML file not found: ${RAML_FILE}`);
|
||||
}
|
||||
if (!fs.existsSync(OPENAPI_FILE)) {
|
||||
throw new Error(`OpenAPI file not found: ${OPENAPI_FILE}`);
|
||||
}
|
||||
|
||||
// Create staging directory
|
||||
if (fs.existsSync(stagingDir)) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
|
||||
// Copy files to staging directory
|
||||
fs.copyFileSync(RAML_FILE, path.join(stagingDir, 'api.raml'));
|
||||
fs.copyFileSync(OPENAPI_FILE, path.join(stagingDir, 'openapi-merged.json'));
|
||||
|
||||
// Create version.json
|
||||
const versionJson = createVersionJson(version, commitSha);
|
||||
fs.writeFileSync(path.join(stagingDir, 'version.json'), JSON.stringify(versionJson, null, 2));
|
||||
|
||||
// Create README.md
|
||||
const readme = createReadme(version, commitSha);
|
||||
fs.writeFileSync(path.join(stagingDir, 'README.md'), readme);
|
||||
|
||||
// Create tar.gz archive
|
||||
console.log(`Creating archive: ${archivePath}`);
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const archive = archiver('tar', { gzip: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
output.on('close', () => {
|
||||
console.log(`✓ Archive created: ${archivePath}`);
|
||||
console.log(` Size: ${archive.pointer()} bytes`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(stagingDir, false);
|
||||
archive.finalize();
|
||||
}).then(() => {
|
||||
// Clean up staging directory
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
packageRaml()
|
||||
.then(() => {
|
||||
console.log('RAML packaging complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to package RAML:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { packageRaml };
|
||||
@@ -189,6 +189,11 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
]
|
||||
}));
|
||||
|
||||
// Serve the raw OpenAPI spec as JSON
|
||||
app.get('/api/swagger.json', (req, res) => {
|
||||
res.json(swaggerSpec);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
Reference in New Issue
Block a user