Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 130 additions & 9 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
name: Backend CI
# This workflow is triggered on push and pull request events for the main and master branches, specifically when changes are made to the BackEnd directory or the workflow file itself. It consists of two jobs: "build-and-lint" and "backend-ci-gate".
# Triggered on push/PR for main/master when BackEnd files or this workflow change.
# Jobs:
# 1. build-and-lint – TypeScript compilation, linting, formatting
# 2. openapi-check – headless OpenAPI spec generation + artifact upload
# 3. backend-ci-gate – aggregate gate: fails if either upstream job fails

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
push:
branches: [main, master]
Expand All @@ -14,8 +19,11 @@ on:
paths:
- "BackEnd/**"
- ".github/workflows/backend-ci.yml"
# The workflow consists of two jobs: "build-and-lint" and "backend-ci-gate". The "build-and-lint" job is responsible for checking out the code, setting up Node.js, caching npm dependencies, installing dependencies, compiling TypeScript, linting the code, and checking code formatting. The "backend-ci-gate" job runs after the "build-and-lint" job and checks if all previous steps were successful. If any step fails, it will exit with an error message.

jobs:
# ---------------------------------------------------------------------------
# Job 1: Build, Lint & Format
# ---------------------------------------------------------------------------
build-and-lint:
name: Build, Lint & Format
runs-on: ubuntu-latest
Expand Down Expand Up @@ -57,25 +65,136 @@ jobs:
- name: Format check
run: npx prettier --check "src/**/*.ts" "test/**/*.ts"

# ---------------------------------------------------------------------------
# Job 2: OpenAPI Spec Generation & Artifact Upload [BE-116]
# ---------------------------------------------------------------------------
openapi-check:
name: OpenAPI Generation Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: BackEnd

# Minimal stubs so the NestJS app module can bootstrap without real services.
# The generate-openapi.ts script also sets these itself, but declaring them
# here makes the CI environment explicit and auditable.
env:
NODE_ENV: openapi-gen
DB_HOST: localhost
DB_PORT: "5432"
DB_USERNAME: postgres
DB_PASSWORD: password
DB_DATABASE: stellar_earn
DATABASE_URL: postgresql://postgres:password@localhost:5432/stellar_earn
REDIS_HOST: localhost
REDIS_PORT: "6379"
REDIS_URL: redis://localhost:6379
JWT_SECRET: openapi-gen-secret
JWT_PRIVATE_KEY: dummy
JWT_PUBLIC_KEY: dummy
STELLAR_NETWORK: testnet
HORIZON_URL: https://horizon-testnet.stellar.org
SOROBAN_RPC_URL: https://soroban-testnet.stellar.org
SOROBAN_SECRET_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
SENDGRID_API_KEY: SG.dummy
EMAIL_FROM_ADDRESS: noreply@stellarearn.com
EMAIL_FROM_NAME: StellarEarn
APP_URL: http://localhost:3000
STELLAR_MOCK_CURRENT_LEDGER: "60000000"

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-backend-npm-${{ hashFiles('BackEnd/package-lock.json') }}
restore-keys: |
${{ runner.os }}-backend-npm-

- name: Install dependencies
run: npm ci

- name: Generate OpenAPI specification
run: npm run openapi:generate

- name: Validate generated spec
# Confirm the file exists, is valid JSON, has an "openapi" field,
# and exposes at least one path.
run: |
SPEC_FILE="dist/openapi/openapi.json"

if [ ! -f "$SPEC_FILE" ]; then
echo "❌ OpenAPI spec file not found at $SPEC_FILE"
exit 1
fi

# Validate JSON syntax
if ! python3 -c "import json,sys; json.load(open('$SPEC_FILE'))" 2>/dev/null; then
echo "❌ $SPEC_FILE is not valid JSON"
exit 1
fi

OPENAPI_VERSION=$(python3 -c "import json; d=json.load(open('$SPEC_FILE')); print(d.get('openapi',''))")
PATH_COUNT=$(python3 -c "import json; d=json.load(open('$SPEC_FILE')); print(len(d.get('paths',{})))")

echo "openapi field : $OPENAPI_VERSION"
echo "path count : $PATH_COUNT"

if [ -z "$OPENAPI_VERSION" ]; then
echo "❌ 'openapi' field is missing or empty in the spec"
exit 1
fi

if [ "$PATH_COUNT" -eq 0 ]; then
echo "❌ Generated spec contains no paths – check that controllers are wired up correctly"
exit 1
fi

echo "✅ OpenAPI spec is valid (openapi=$OPENAPI_VERSION, paths=$PATH_COUNT)"

- name: Upload OpenAPI spec artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: openapi-spec-${{ github.sha }}
path: BackEnd/dist/openapi/openapi.json
retention-days: 30

# ---------------------------------------------------------------------------
# Job 3: Aggregate CI Gate
# ---------------------------------------------------------------------------
backend-ci-gate:
name: Backend CI Gate
runs-on: ubuntu-latest
needs: [build-and-lint]
needs: [build-and-lint, openapi-check]
if: always()

steps:
- name: Validate all checks passed
run: |
build_status="${{ needs.build-and-lint.result }}"
openapi_status="${{ needs.openapi-check.result }}"

echo "Build & lint: $build_status"
echo "Build & lint : $build_status"
echo "OpenAPI check : $openapi_status"

if [ "$build_status" != "success" ]; then
echo "Build or lint failed"
echo "❌ Build or lint failed"
exit 1
fi

if [ "$openapi_status" != "success" ]; then
echo "❌ OpenAPI generation check failed"
exit 1
fi

echo "All backend CI checks passed"
echo "All backend CI checks passed"
exit 0

- name: Workflow Summary
Expand All @@ -86,11 +205,13 @@ jobs:
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Build & Lint | ${{ needs.build-and-lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| OpenAPI Check | ${{ needs.openapi-check.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

if [ "${{ needs.build-and-lint.result }}" != "success" ]; then

if [ "${{ needs.build-and-lint.result }}" != "success" ] || \
[ "${{ needs.openapi-check.result }}" != "success" ]; then
echo "### ❌ Failed Checks" >> $GITHUB_STEP_SUMMARY
echo "The build or lint step failed. Please check the job logs for details." >> $GITHUB_STEP_SUMMARY
echo "One or more backend CI checks failed. Please review the job logs above." >> $GITHUB_STEP_SUMMARY
else
echo "### ✅ All Checks Passed" >> $GITHUB_STEP_SUMMARY
echo "All backend CI checks completed successfully." >> $GITHUB_STEP_SUMMARY
Expand Down
1 change: 1 addition & 0 deletions BackEnd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"migration:test:performance": "ts-node scripts/test-migrations.ts performance",
"migration:test:syntax": "ts-node scripts/test-migration-syntax.ts",
"verify:indexes": "ts-node scripts/verify-indexes.ts",
"openapi:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts",
"schema:sync": "bun run typeorm -- schema:sync",
"schema:drop": "bun run typeorm -- schema:drop",
"schema:log": "bun run typeorm -- schema:log"
Expand Down
180 changes: 180 additions & 0 deletions BackEnd/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* generate-openapi.ts
*
* Headless NestJS bootstrap that produces the OpenAPI JSON specification
* without starting an HTTP server or connecting to external services.
*
* Usage:
* ts-node scripts/generate-openapi.ts [--output <path>]
*
* Environment:
* OPENAPI_OUTPUT_PATH Override the default output file path.
* Defaults to: dist/openapi/openapi.json
*
* This script is intentionally kept lightweight – it uses NestFactory.create()
* with a no-op HTTP adapter so that no real TCP port is opened and no external
* service connections are attempted.
*/

import * as fs from 'fs';
import * as path from 'path';

// ---------------------------------------------------------------------------
// Minimal environment stubs – allow the app module to load without real
// external services. These are only set when the variable is not already
// present so that a real environment (staging CI, etc.) always wins.
// ---------------------------------------------------------------------------
const CI_ENV_DEFAULTS: Record<string, string> = {
NODE_ENV: 'openapi-gen',
DB_HOST: 'localhost',
DB_PORT: '5432',
DB_USERNAME: 'postgres',
DB_PASSWORD: 'password',
DB_DATABASE: 'stellar_earn',
DATABASE_URL: 'postgresql://postgres:password@localhost:5432/stellar_earn',
REDIS_HOST: 'localhost',
REDIS_PORT: '6379',
REDIS_URL: 'redis://localhost:6379',
JWT_SECRET: 'openapi-gen-secret',
JWT_PRIVATE_KEY: 'dummy',
JWT_PUBLIC_KEY: 'dummy',
STELLAR_NETWORK: 'testnet',
HORIZON_URL: 'https://horizon-testnet.stellar.org',
SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org',
SOROBAN_SECRET_KEY:
'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
SENDGRID_API_KEY: 'SG.dummy',
EMAIL_FROM_ADDRESS: 'noreply@stellarearn.com',
EMAIL_FROM_NAME: 'StellarEarn',
APP_URL: 'http://localhost:3000',
STELLAR_MOCK_CURRENT_LEDGER: '60000000',
};

for (const [key, value] of Object.entries(CI_ENV_DEFAULTS)) {
if (!process.env[key]) {
process.env[key] = value;
}
}

// ---------------------------------------------------------------------------
// Dynamic import of NestJS + Swagger after env stubs are applied
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
// Parse --output CLI flag
const outputFlagIndex = process.argv.indexOf('--output');
const outputPath =
outputFlagIndex !== -1 && process.argv[outputFlagIndex + 1]
? process.argv[outputFlagIndex + 1]
: process.env['OPENAPI_OUTPUT_PATH'] ??
path.resolve(__dirname, '..', 'dist', 'openapi', 'openapi.json');

console.log(`[openapi-gen] Generating OpenAPI spec → ${outputPath}`);

// Lazy imports keep compile-time deps minimal when the script is excluded
// from the main build.
const { NestFactory } = await import('@nestjs/core');
const { SwaggerModule, DocumentBuilder } = await import('@nestjs/swagger');
const { VersioningType, BadRequestException, ValidationPipe } =
await import('@nestjs/common');
const { AppModule } = await import('../src/app.module');
const {
API_VERSION_CONFIG,
extractApiVersion,
} = await import('../src/config/versioning.config');
const { ConfigService } = await import('@nestjs/config');

// Bootstrap without listening on a port
const app = await NestFactory.create(AppModule, {
logger: false, // suppress noisy startup logs in CI
abortOnError: false,
});

// Replicate the same global configuration used in main.ts so that the
// generated spec accurately reflects the real application.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
disableErrorMessages: false,
exceptionFactory: (errors) =>
new BadRequestException({
message: 'Validation failed',
errors: errors.map((e) => ({
property: e.property,
constraints: e.constraints,
})),
}),
}),
);

app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.CUSTOM,
defaultVersion: API_VERSION_CONFIG.defaultVersion,
extractor: (request) =>
extractApiVersion(request as any) || API_VERSION_CONFIG.defaultVersion,
});

const configService = app.get(ConfigService);
const title = configService.get<string>('APP_NAME') ?? 'StellarEarn API';
const version = configService.get<string>('API_VERSION') ?? '1.0';
const description =
configService.get<string>('API_DESCRIPTION') ??
'Quest-based earning platform on Stellar blockchain';

const builder = new DocumentBuilder()
.setTitle(title)
.setDescription(
`${description}\n\nSupported API versions: v1, v2. Use path versioning (/api/v1/*, /api/v2/*) and/or header versioning (X-API-Version: 1).`,
)
.setVersion(version)
.addServer('/api/v1', 'API v1')
.addServer('/api/v2', 'API v2')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'JWT-auth',
)
.addTag('Authentication')
.addTag('Health', 'System health and readiness probes');

const document = SwaggerModule.createDocument(app, builder.build(), {
deepScanRoutes: true,
});

// Validate the generated document has the expected structure
if (!document.openapi) {
throw new Error('[openapi-gen] Generated document is missing "openapi" field');
}
if (!document.info) {
throw new Error('[openapi-gen] Generated document is missing "info" field');
}
if (!document.paths || Object.keys(document.paths).length === 0) {
console.warn(
'[openapi-gen] WARNING: Generated document contains no paths. ' +
'This may indicate that no controllers were discovered.',
);
}

const pathCount = Object.keys(document.paths ?? {}).length;
console.log(
`[openapi-gen] Spec generated successfully – ` +
`openapi: ${document.openapi}, paths: ${pathCount}`,
);

// Ensure output directory exists
const outputDir = path.dirname(outputPath);
fs.mkdirSync(outputDir, { recursive: true });

fs.writeFileSync(outputPath, JSON.stringify(document, null, 2), 'utf8');
console.log(`[openapi-gen] Written to ${outputPath}`);

// Close the app without starting the HTTP server
await app.close();
process.exit(0);
}

main().catch((error: unknown) => {
console.error('[openapi-gen] Fatal error:', error);
process.exit(1);
});
Loading