mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
1 parent
bc17922b02
commit
ece622e800
471 files changed
+56262
-35932
No files matched your search
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user