feat: attachments on tickets and comments
Files are uploaded over REST, stored on disk (a volume in Docker) and recorded on the ticket as events; the bytes are written first, so no attachment ever points at a missing file. Only images, video, audio, PDF and plain text are shown inline, everything with nosniff and a sandbox CSP. The web shows thumbnails, uploads by drag and drop or by pasting a screenshot, and attaches files to new comments. Also adds `nx run api:contracts`, which writes schema.gql and openapi.json without starting the API or any database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3237,6 +3237,308 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/attachments": {
|
||||
"post": {
|
||||
"description": "Multipart with the file as `file` and, optionally, `commentId` to place it under a comment. Up to ATTACHMENTS_MAX_MB megabytes (25 by default) and 50 files per ticket.",
|
||||
"operationId": "AttachmentsController_attach",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issue",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Ticket id or key",
|
||||
"schema": {
|
||||
"example": "WEB-12",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AttachFileDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Attach a file to a ticket (members)",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/attachments/{attachmentId}": {
|
||||
"get": {
|
||||
"operationId": "AttachmentsController_download",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issue",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Ticket id or key",
|
||||
"schema": {
|
||||
"example": "WEB-12",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attachmentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The file"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Download one of a ticket's files",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "AttachmentsController_remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issue",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Ticket id or key",
|
||||
"schema": {
|
||||
"example": "WEB-12",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attachmentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Remove a file (whoever uploaded it, or an owner/admin)",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}": {
|
||||
"patch": {
|
||||
"operationId": "CommentsController_edit",
|
||||
@@ -5309,6 +5611,50 @@
|
||||
"issueKey"
|
||||
]
|
||||
},
|
||||
"AttachmentDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"example": "captura-login.png"
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string",
|
||||
"example": "image/png"
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"description": "Bytes",
|
||||
"example": 48213
|
||||
},
|
||||
"uploadedBy": {
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
},
|
||||
"uploadedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"commentId": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "uuid",
|
||||
"description": "The comment it was attached to, if any"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"fileName",
|
||||
"mimeType",
|
||||
"size",
|
||||
"uploadedBy",
|
||||
"uploadedAt",
|
||||
"commentId"
|
||||
]
|
||||
},
|
||||
"IssueDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5423,6 +5769,12 @@
|
||||
"$ref": "#/components/schemas/IssueLinkDto"
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AttachmentDto"
|
||||
}
|
||||
},
|
||||
"commentCount": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -5460,6 +5812,7 @@
|
||||
"storyPoints",
|
||||
"dueDate",
|
||||
"links",
|
||||
"attachments",
|
||||
"commentCount",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -6156,6 +6509,24 @@
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"AttachFileDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "The file"
|
||||
},
|
||||
"commentId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "One of the ticket's comments, to show the file under it"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"ReleaseStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:api",
|
||||
"type:app"
|
||||
],
|
||||
"tags": ["scope:api", "type:app"],
|
||||
"targets": {
|
||||
"prune-lockfile": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"executor": "@nx/js:prune-lockfile",
|
||||
"outputs": [
|
||||
@@ -23,32 +18,23 @@
|
||||
}
|
||||
},
|
||||
"copy-workspace-modules": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"outputs": [
|
||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
||||
],
|
||||
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||
"executor": "@nx/js:copy-workspace-modules",
|
||||
"options": {
|
||||
"buildTarget": "build"
|
||||
}
|
||||
},
|
||||
"prune": {
|
||||
"dependsOn": [
|
||||
"prune-lockfile",
|
||||
"copy-workspace-modules"
|
||||
],
|
||||
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||
"executor": "nx:noop"
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/js:node",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"buildTarget": "api:build",
|
||||
"runBuildTargetDependencies": false
|
||||
@@ -73,6 +59,13 @@
|
||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||
},
|
||||
"cache": false
|
||||
},
|
||||
"contracts": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
|
||||
},
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ input AreaOptionInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}
|
||||
"""
|
||||
type Attachment {
|
||||
"""The comment it was attached to, if any"""
|
||||
commentId: ID
|
||||
fileName: String!
|
||||
id: ID!
|
||||
mimeType: String!
|
||||
|
||||
"""Bytes"""
|
||||
size: Int!
|
||||
uploadedAt: String!
|
||||
uploadedBy: UserRef!
|
||||
}
|
||||
|
||||
type BoardColumn {
|
||||
issues: [Issue!]!
|
||||
status: String!
|
||||
@@ -168,6 +184,7 @@ type Issue {
|
||||
"""An area id"""
|
||||
area: String
|
||||
assignee: UserRef
|
||||
attachments: [Attachment!]!
|
||||
commentCount: Int!
|
||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||
createdAt: String!
|
||||
@@ -329,6 +346,11 @@ type Mutation {
|
||||
Ship the release. Fails while it has unfinished tickets unless force = true.
|
||||
"""
|
||||
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
|
||||
"""
|
||||
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||
|
||||
"""Put a ticket in a release (null removes it)"""
|
||||
|
||||
117
apps/api/scripts/contracts.ts
Normal file
117
apps/api/scripts/contracts.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
|
||||
* API or touching a database: `npx nx run api:contracts`.
|
||||
*
|
||||
* The running API writes the same files on start in development; this is for
|
||||
* when no database is at hand — a CI job, a laptop without Docker running.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
GRAPHQL_SDL_FILE_HEADER,
|
||||
GraphQLSchemaBuilderModule,
|
||||
GraphQLSchemaFactory,
|
||||
} from '@nestjs/graphql';
|
||||
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
|
||||
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
|
||||
import { lexicographicSortSchema, printSchema } from 'graphql';
|
||||
import { AppModule } from '../src/app/app.module';
|
||||
import { buildOpenApiDocument } from '../src/app/openapi';
|
||||
|
||||
/** The GraphQL resolvers a presenters module declares. */
|
||||
function resolversOf(module: Type): Type[] {
|
||||
const providers: unknown[] =
|
||||
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
|
||||
return providers.filter(
|
||||
(provider): provider is Type =>
|
||||
typeof provider === 'function' &&
|
||||
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
|
||||
Reflect.getMetadataKeys(provider).some((key) =>
|
||||
String(key).startsWith('graphql:resolver'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function graphqlSchema(): Promise<string> {
|
||||
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
|
||||
logger: false,
|
||||
});
|
||||
await app.init();
|
||||
const schema = await app
|
||||
.get(GraphQLSchemaFactory)
|
||||
.create([
|
||||
...resolversOf(IdentityPresentersModule),
|
||||
...resolversOf(TrackerPresentersModule),
|
||||
]);
|
||||
await app.close();
|
||||
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
|
||||
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
|
||||
}
|
||||
|
||||
/** The REST controllers a module declares. */
|
||||
function controllersOf(module: Type): Type[] {
|
||||
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
|
||||
* reads the controllers' metadata and never calls them.
|
||||
*/
|
||||
function standIns(controllers: Type[]): Provider[] {
|
||||
const tokens = new Set<unknown>();
|
||||
for (const controller of controllers) {
|
||||
const types: unknown[] =
|
||||
Reflect.getMetadata('design:paramtypes', controller) ?? [];
|
||||
const injected: { index: number; param: unknown }[] =
|
||||
Reflect.getMetadata('self:paramtypes', controller) ?? [];
|
||||
types.forEach((type, index) =>
|
||||
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
|
||||
);
|
||||
}
|
||||
return [...tokens].map((token) => ({
|
||||
provide: token as InjectionToken,
|
||||
useValue: {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function openApi(): Promise<string> {
|
||||
// Only the controllers, with stand-ins for their dependencies: nothing
|
||||
// connects to Postgres, Mongo, Redis or Pireon.
|
||||
const controllers = [
|
||||
...controllersOf(AppModule),
|
||||
...controllersOf(IdentityPresentersModule),
|
||||
...controllersOf(TrackerPresentersModule),
|
||||
];
|
||||
|
||||
@Module({ controllers, providers: standIns(controllers) })
|
||||
class ContractsModule {}
|
||||
|
||||
const app = await NestFactory.create(ContractsModule, { logger: false });
|
||||
app.setGlobalPrefix('api');
|
||||
const document = buildOpenApiDocument(app, {
|
||||
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
|
||||
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean),
|
||||
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
|
||||
});
|
||||
return JSON.stringify(document, null, 2) + '\n';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = process.cwd();
|
||||
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
|
||||
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
21
apps/api/scripts/register.cjs
Normal file
21
apps/api/scripts/register.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Lets Node run the TypeScript sources of the monorepo directly: compiles
|
||||
* them on the fly (decorator metadata included, which Nest needs) and
|
||||
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
|
||||
*/
|
||||
const { join } = require('node:path');
|
||||
const root = join(__dirname, '../../..');
|
||||
|
||||
require('ts-node').register({
|
||||
project: join(root, 'apps/api/tsconfig.app.json'),
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'commonjs',
|
||||
moduleResolution: 'node10',
|
||||
ignoreDeprecations: '6.0',
|
||||
},
|
||||
});
|
||||
require('tsconfig-paths').register({
|
||||
baseUrl: root,
|
||||
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
|
||||
});
|
||||
@@ -42,4 +42,8 @@ export const configSchema = Joi.object({
|
||||
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||
|
||||
// Attached files: where they are kept (a volume in Docker) and how big one can be.
|
||||
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
||||
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||
import {
|
||||
PIREON_BEARER,
|
||||
PIREON_OAUTH,
|
||||
@@ -35,19 +35,19 @@ show the previous version (or 404 right after a create).
|
||||
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
||||
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
||||
*/
|
||||
export function setupOpenApi(app: INestApplication): void {
|
||||
const config = app.get(ConfigService);
|
||||
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
||||
export interface OpenApiSettings {
|
||||
pireonBaseUrl: string;
|
||||
scopes: string[];
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
const pireon =
|
||||
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
|
||||
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
/** The OpenAPI document of the REST controllers. Needs no database. */
|
||||
export function buildOpenApiDocument(
|
||||
app: INestApplication,
|
||||
settings: OpenApiSettings,
|
||||
): OpenAPIObject {
|
||||
const pireon = settings.pireonBaseUrl;
|
||||
const scopes = settings.scopes;
|
||||
const scopeDocs = Object.fromEntries(
|
||||
scopes.map((scope) => [
|
||||
scope,
|
||||
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
|
||||
refreshUrl: tokenUrl,
|
||||
scopes: scopeDocs,
|
||||
},
|
||||
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
|
||||
...(settings.passwordGrant
|
||||
? {
|
||||
password: {
|
||||
tokenUrl,
|
||||
@@ -125,6 +125,27 @@ export function setupOpenApi(app: INestApplication): void {
|
||||
}
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
||||
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
||||
*/
|
||||
export function setupOpenApi(app: INestApplication): void {
|
||||
const config = app.get(ConfigService);
|
||||
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
||||
|
||||
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
const document = buildOpenApiDocument(app, {
|
||||
pireonBaseUrl:
|
||||
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
|
||||
scopes,
|
||||
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
|
||||
});
|
||||
|
||||
const file = config.get<string>('OPENAPI_FILE');
|
||||
if (file) {
|
||||
writeFileSync(
|
||||
|
||||
Reference in New Issue
Block a user