feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

+1 -1
View File
@@ -172,7 +172,7 @@ export class OpenAPIGenerator {
referencedSchemas: Set<string>,
): Record<string, OpenAPISchema> {
const publishedSchemas = new Map<string, OpenAPISchema>();
for (const name of referencedSchemas) {
for (const name of [...referencedSchemas, ...OpenAPIGeneratorCatalog.alwaysPublishedSchemas]) {
assert(Object.hasOwn(allSchemas, name), `Referenced OpenAPI schema is missing: ${name}`);
publishedSchemas.set(name, allSchemas[name]);
}
+1
View File
@@ -34,6 +34,7 @@ export interface ExtractedRoute {
explicitOperationId: string | null;
explicitDescription: string | null;
explicitStatusCodes: Array<number> | null;
errorStatusCodes: Array<number>;
explicitSecurity: Array<string> | null;
oauth2RequiredScopes: Array<string> | null;
oauth2ScopeMode: 'all' | 'any' | null;
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import {type APIErrorCode, APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
import {ERROR_I18N_MESSAGES} from '@fluxer/errors/src/i18n/ErrorI18nMessages';
import {OpenAPIGenerator} from '@fluxer/openapi/src/OpenAPIGenerator';
import type {OpenAPIDocument} from '@fluxer/openapi/src/OpenAPITypes';
import {visitOpenAPISchemaObjects} from '@fluxer/openapi/src/OpenAPISchemaVisitor';
import type {OpenAPIDocument, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes';
import {beforeAll, describe, expect, it} from 'vitest';
const REPOSITORY_PATH = fileURLToPath(new URL('../../../../', import.meta.url));
@@ -175,3 +180,120 @@ describe('OpenAPI generation from API controllers', () => {
expect(responses).not.toHaveProperty('204');
});
});
const EU_COUNTRY_CODES = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
];
function errorCodeMessage(code: APIErrorCode): string {
return ERROR_I18N_MESSAGES[ErrorCodeToI18nKey[code]];
}
function resolveRef(document: OpenAPIDocument, schema: OpenAPISchema | undefined): OpenAPISchema {
const name = schema?.$ref?.replace('#/components/schemas/', '');
expect(name).toBeDefined();
return document.components.schemas[name ?? ''];
}
function schemasWithEnum(document: OpenAPIDocument, values: ReadonlyArray<string>): Array<OpenAPISchema> {
const found: Array<OpenAPISchema> = [];
visitOpenAPISchemaObjects(document, (schema) => {
if (Array.isArray(schema.enum) && schema.enum.join(',') === values.join(',')) found.push(schema);
});
return found;
}
describe('published public and admin documents', () => {
const documents = new Map<'public' | 'admin', OpenAPIDocument>();
function documentFor(scope: 'public' | 'admin'): OpenAPIDocument {
const document = documents.get(scope);
assert(document, `the ${scope} document was not generated`);
return document;
}
beforeAll(async () => {
documents.set(
'public',
await new OpenAPIGenerator({
basePath: REPOSITORY_PATH,
routeScope: 'public',
schemaTarget: 'draft-2020-12',
}).generate(),
);
documents.set(
'admin',
await new OpenAPIGenerator({
basePath: REPOSITORY_PATH,
routeScope: 'admin',
schemaTarget: 'openapi-3.0',
}).generate(),
);
});
it.each(['public', 'admin'] as const)(
'publishes every API error code with its message in the %s document',
(scope) => {
const {schemas} = documentFor(scope).components;
const codes = Object.values(APIErrorCodes);
expect(schemas.APIErrorCode.type).toBe('string');
expect(schemas.APIErrorCode.enum).toEqual(codes);
expect(schemas.APIErrorCode['x-enumDescriptions']).toEqual(codes.map(errorCodeMessage));
for (const name of ['Error', 'ThrottledError']) {
const code = schemas[name].properties?.code;
expect(code).toMatchObject({type: 'string', description: expect.stringContaining('APIErrorCode')});
expect(code).not.toHaveProperty('enum');
}
},
);
it('publishes the EU country codes once and references them from every DSA report body', () => {
const document = documentFor('public');
const euCountryCode = document.components.schemas.EuCountryCode;
expect(euCountryCode.enum).toEqual(EU_COUNTRY_CODES);
expect(euCountryCode.type).toBe('string');
expect(schemasWithEnum(document, EU_COUNTRY_CODES)).toEqual([euCountryCode]);
const body = resolveRef(
document,
document.paths['/reports/dsa'].post.requestBody?.content['application/json']?.schema,
);
expect(body.oneOf).toHaveLength(3);
for (const branch of body.oneOf ?? []) {
const properties = resolveRef(document, branch).properties;
expect(properties?.reporter_country_of_residence).toEqual({
$ref: '#/components/schemas/EuCountryCode',
description: 'EU country code of the reporter residence',
});
expect(properties).not.toHaveProperty('reporter_fluxer_tag');
}
});
it('describes only the report statuses that exist', () => {
const status = documentFor('public').components.schemas.ReportResponse.properties?.status;
expect(status).toEqual({type: 'string', description: 'Current status of the report (pending, resolved)'});
});
});
@@ -51,6 +51,7 @@ const RouteMetadata = z.strictObject({
.transform((value) => (typeof value === 'number' ? [value] : value))
.optional(),
bodylessStatusCodes: z.array(MetadataStatusCode).optional(),
errorStatusCodes: z.array(MetadataStatusCode).optional(),
security: z
.union([MetadataSecurityScheme, z.array(MetadataSecurityScheme)])
.transform((value) => (typeof value === 'string' ? [value] : value))
@@ -172,6 +173,7 @@ interface MiddlewareInfo {
explicitOperationId?: string;
explicitDescription?: string;
explicitStatusCodes?: Array<number> | null;
errorStatusCodes?: Array<number> | null;
explicitSecurity?: Array<string> | null;
oauth2RequiredScopes?: Array<string> | null;
oauth2ScopeMode?: 'all' | 'any';
@@ -215,6 +217,7 @@ function extractOpenAPIMetadata(args: ReadonlyArray<Node>, context: MetadataCont
explicitOperationId: metadata.operationId,
explicitDescription: metadata.description,
explicitStatusCodes: metadata.statusCode,
errorStatusCodes: metadata.errorStatusCodes,
explicitSecurity: metadata.security,
explicitTags: metadata.tags,
explicitDeprecated: metadata.deprecated,
@@ -410,6 +413,7 @@ function buildRoute(
explicitOperationId: null,
explicitDescription: null,
explicitStatusCodes: null,
errorStatusCodes: [],
explicitSecurity: null,
oauth2RequiredScopes: null,
oauth2ScopeMode: null,
@@ -461,6 +465,7 @@ function buildRoute(
if (middleware.explicitOperationId) route.explicitOperationId = middleware.explicitOperationId;
if (middleware.explicitDescription) route.explicitDescription = middleware.explicitDescription;
if (middleware.explicitStatusCodes) route.explicitStatusCodes = middleware.explicitStatusCodes;
if (middleware.errorStatusCodes) route.errorStatusCodes = middleware.errorStatusCodes;
if (middleware.explicitSecurity) route.explicitSecurity = middleware.explicitSecurity;
if (middleware.oauth2RequiredScopes && middleware.oauth2ScopeMode) {
if (route.oauth2ScopeMode && route.oauth2ScopeMode !== middleware.oauth2ScopeMode) {
@@ -9,6 +9,7 @@ import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOper
import type {ExtractedRoute} from '@fluxer/openapi/src/OpenAPITypes';
import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
import {DonationManageQuery} from '@fluxer/schema/src/domains/donation/DonationSchemas';
import {DsaReportEmailSendRequest, OkResponse} from '@fluxer/schema/src/domains/report/ReportSchemas';
import {beforeAll, describe, expect, it} from 'vitest';
const API_PACKAGE_PATH = path.join(fileURLToPath(new URL('../../../../../', import.meta.url)), 'fluxer_api');
@@ -69,6 +70,29 @@ describe('discoverControllerFiles', () => {
'Bodyless status 299 is not declared for GET /donations/manage',
);
});
it('documents the extra error statuses a route declares', () => {
const route = routes.find((route) => route.explicitOperationId === 'send_dsa_report_email');
assert(route);
expect(route.errorStatusCodes).toEqual([503]);
const schemaRegistry = new SchemaRegistry();
schemaRegistry.registerZod('DsaReportEmailSendRequest', DsaReportEmailSendRequest);
schemaRegistry.registerZod('OkResponse', OkResponse);
const builder = new OpenAPIOperationBuilder({schemaRegistry, usedOperationIds: new Set()});
const {responses} = builder.buildOperation(route);
expect(Object.keys(responses).sort()).toEqual(['200', '400', '429', '500', '503']);
expect(responses['503']).toEqual({
description: 'Service Unavailable - The request could not be completed right now',
content: {'application/json': {schema: {$ref: '#/components/schemas/Error'}}},
});
expect(() => builder.buildOperation({...route, explicitOperationId: 'other', errorStatusCodes: [418]})).toThrow(
'No documented error response for status 418',
);
});
it('adds no extra error status to a route that declares none', () => {
const route = routes.find((route) => route.explicitOperationId === 'verify_dsa_report_email');
assert(route);
expect(route.errorStatusCodes).toEqual([]);
});
it('resolves paths and metadata from shared admin route registrations', () => {
const route = routes.find((route) => route.explicitOperationId === 'create_admin_blocklist_entry');
expect(route).toMatchObject({
@@ -81,6 +81,7 @@ const BUILT_IN_SCHEMAS: ReadonlyArray<readonly [string, OpenAPISchema]> = [
['ThrottledError', THROTTLED_ERROR_SCHEMA],
['APIErrorCode', APIErrorCodeSchema],
];
const ALWAYS_PUBLISHED_SCHEMAS: ReadonlyArray<string> = ['APIErrorCode'];
interface IOpenAPIGeneratorCatalog {
readonly excluded: {
readonly prefixes: ReadonlyArray<string>;
@@ -92,11 +93,12 @@ interface IOpenAPIGeneratorCatalog {
};
readonly securitySchemes: Record<string, OpenAPISecurityScheme>;
readonly builtInSchemas: ReadonlyArray<readonly [string, OpenAPISchema]>;
readonly alwaysPublishedSchemas: ReadonlyArray<string>;
}
export const OpenAPIGeneratorCatalog: IOpenAPIGeneratorCatalog = {
excluded: {
prefixes: ['/test/'],
paths: new Set<string>(['/_rpc', '/oauth2/authorize']),
paths: new Set<string>(['/_rpc', '/oauth2/authorize', '/reports/guild', '/reports/message', '/reports/user']),
},
tags: {
order: TAG_DEFINITIONS.map(({name}) => name),
@@ -104,6 +106,7 @@ export const OpenAPIGeneratorCatalog: IOpenAPIGeneratorCatalog = {
},
securitySchemes: SECURITY_SCHEMES,
builtInSchemas: BUILT_IN_SCHEMAS,
alwaysPublishedSchemas: ALWAYS_PUBLISHED_SCHEMAS,
};
export function isExcludedRoutePath(routePath: string): boolean {
if (OpenAPIGeneratorCatalog.excluded.paths.has(routePath)) {
@@ -239,7 +239,7 @@ export class OpenAPIOperationBuilder {
},
};
}
Object.assign(responses, getErrorResponses(requiresAuth));
Object.assign(responses, getErrorResponses(requiresAuth, route.errorStatusCodes));
return responses;
}
}
@@ -90,8 +90,19 @@ const COMMON_RESPONSES: Record<string, OpenAPIResponse> = {
},
},
},
'503': {
description: 'Service Unavailable - The request could not be completed right now',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/Error'},
},
},
},
};
export function getErrorResponses(requiresAuth: boolean): Record<string, OpenAPIResponse> {
export function getErrorResponses(
requiresAuth: boolean,
errorStatusCodes: ReadonlyArray<number> = [],
): Record<string, OpenAPIResponse> {
const responses: Record<string, OpenAPIResponse> = {
'400': COMMON_RESPONSES['400'],
'429': COMMON_RESPONSES['429'],
@@ -101,5 +112,12 @@ export function getErrorResponses(requiresAuth: boolean): Record<string, OpenAPI
responses['401'] = COMMON_RESPONSES['401'];
responses['403'] = COMMON_RESPONSES['403'];
}
for (const code of errorStatusCodes) {
const response = COMMON_RESPONSES[String(code)];
if (!response) {
throw new Error(`No documented error response for status ${code}`);
}
responses[String(code)] = response;
}
return responses;
}