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
44 changes: 44 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,44 @@ jobs:
run:
working-directory: ./apps/backend

# Generating the OpenAPI spec boots the full Nest DI graph (TypeORM +
# Redis-backed cache/rate-limit modules), so the freshness check below
# needs real service containers, mirroring the root docker-compose.yml.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lumenpulse
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
NODE_ENV: test
PORT: '3000'
DB_HOST: localhost
DB_PORT: '5432'
DB_USERNAME: postgres
DB_PASSWORD: postgres
DB_DATABASE: lumenpulse
JWT_SECRET: ci-openapi-spec-secret
STELLAR_SERVER_SECRET: SB6RIPM3GJQ7RP3Q6R5F3QIBYZHP4N27SGGCQ3R4LWA2ZKXZWQ3NU3G4

steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down Expand Up @@ -49,6 +87,12 @@ jobs:
- name: Build
run: npm run build

- name: Run database migrations
run: npm run migration:run

- name: Check committed OpenAPI spec is up to date
run: npm run openapi:check

migration-safety:
runs-on: ubuntu-latest
defaults:
Expand Down
60 changes: 60 additions & 0 deletions apps/backend/document/openapi-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Committed OpenAPI Specification

## Artifact path

The full OpenAPI 3 document for the backend is committed at:

```
apps/backend/openapi/openapi.json
```

Any tool that needs a static description of the API — most notably the
webapp's client/type generation script — should read the spec from this
path rather than fetching it from a running server. This keeps client
generation reproducible in CI and in local builds that don't have a backend
process running.

## Regenerating the spec

The spec is produced from the same `DocumentBuilder` config the running
server uses to serve `/api/docs` (see `src/bootstrap/swagger.config.ts`), so
it always matches what `SwaggerModule` would emit at runtime.

```bash
cd apps/backend
npm run openapi:generate
```

This boots the full Nest application (without calling `app.listen`), builds
the document via `SwaggerModule.createDocument`, and writes it to
`openapi/openapi.json`. Because it boots the real DI graph, it needs a
reachable Postgres and Redis — see the root `docker-compose.yml` for the
expected local services, or rely on the CI service containers described
below.

Whenever a controller, DTO, or the Swagger config changes, regenerate and
commit the updated `openapi/openapi.json` alongside the code change.

## CI freshness check

`.github/workflows/backend.yml` runs `npm run openapi:check` after the build
step. That script regenerates the spec and then runs
`git diff --exit-code -- openapi/openapi.json`, so CI fails whenever the
committed artifact is stale relative to the code that produced it. The job
provisions ephemeral Postgres and Redis service containers (matching the
credentials in `test/setup-env.ts`) so the app can boot far enough to build
the document.

## Authentication schemes described in the spec

| Scheme | Type | Used by |
|---|---|---|
| `JWT-auth` | HTTP bearer (`Authorization: Bearer <token>`) | Most authenticated user/admin endpoints (`@ApiBearerAuth('JWT-auth')`) |
| `soroban-ingest-secret` | API key header `x-ingest-secret` | Soroban event ingestion (`POST /soroban-events/ingest`) |
| `webhook-signature` | API key header `x-webhook-signature` | Inbound webhook delivery verification |

Additionally, every mutating endpoint (`POST`/`PUT`/`PATCH`/`DELETE`)
documents the optional `Idempotency-Key` request header handled globally by
`IdempotencyInterceptor`, along with the `409` (duplicate request in
flight) and `422` (key reused with a different body) responses it can
produce. See `src/common/decorators/api-idempotency.decorator.ts`.
4 changes: 3 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts",
"migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts",
"migration:check": "ts-node scripts/check-migrations.ts",
"migration:verify": "ts-node scripts/verify-migrations-schema.ts"
"migration:verify": "ts-node scripts/verify-migrations-schema.ts",
"openapi:generate": "ts-node -r tsconfig-paths/register -r ./test/setup-env.ts scripts/generate-openapi-spec.ts",
"openapi:check": "npm run openapi:generate && git diff --exit-code -- openapi/openapi.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1019.0",
Expand Down
32 changes: 32 additions & 0 deletions apps/backend/scripts/generate-openapi-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import '../src/lib/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import * as fs from 'fs';
import * as path from 'path';
import { AppModule } from '../src/app.module';
import { buildSwaggerConfig } from '../src/bootstrap/swagger.config';

const OUTPUT_PATH = path.resolve(__dirname, '../openapi/openapi.json');

async function generate(): Promise<void> {
// abortOnError: false so a bootstrap failure rejects the promise below
// (and is reported by this script) instead of Nest calling process.exit()
// internally before our own error handling runs.
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn'],
abortOnError: false,
});
const document = SwaggerModule.createDocument(app, buildSwaggerConfig());

fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
fs.writeFileSync(OUTPUT_PATH, `${JSON.stringify(document, null, 2)}\n`);

await app.close();

console.log(`OpenAPI spec written to ${OUTPUT_PATH}`);
}

generate().catch((error) => {
console.error('Failed to generate OpenAPI spec:', error);
process.exitCode = 1;
});
5 changes: 5 additions & 0 deletions apps/backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export class AnalyticsController {
type: ChartDataPointDto,
isArray: true,
})
@ApiResponse({
status: 400,
description: 'Invalid query parameters (interval, range, or asset)',
})
@ApiResponse({ status: 429, description: 'Too many requests' })
async getChartData(
@Query() query: ChartDataQueryDto,
): Promise<ChartDataPointDto[]> {
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/src/analytics/dto/chart-data.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum ChartInterval {
ONE_HOUR = '1h',
Expand Down Expand Up @@ -39,7 +39,21 @@ export class ChartDataQueryDto {
}

export class ChartDataPointDto {
@ApiProperty({
description: 'Start of the bucket, in ISO-8601 format',
example: '2026-08-27T00:00:00.000Z',
})
timestamp: string;

@ApiProperty({
description: 'Average sentiment score for the bucket',
example: 0.42,
})
sentiment: number;

@ApiProperty({
description: 'Number of data points aggregated into the bucket',
example: 128,
})
count: number;
}
12 changes: 10 additions & 2 deletions apps/backend/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ export class AppController {
constructor(private readonly appService: AppService) {}

@Get()
@ApiOperation({ summary: 'Root endpoint' })
@ApiResponse({ status: 200, description: 'Returns Hello World' })
@ApiOperation({
summary: 'Root endpoint',
description:
'Basic liveness/welcome endpoint. Returns a static greeting string; not used for health checks (see /health).',
})
@ApiResponse({
status: 200,
description: 'Returns Hello World',
schema: { type: 'string', example: 'Hello World!' },
})
getHello(): string {
return this.appService.getHello();
}
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class AuditController {
},
},
})
@ApiResponse({
status: 400,
description: 'Invalid limit or offset (must be numeric)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden (admin only)' })
async getAuditLogs(
Expand Down
Loading
Loading