Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: backendでのOpenAPI関連の処理の整理 #10625

Closed
wants to merge 11 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { genOpenapiSpec } from './gen-spec.js';
import { genOpenApiSpec } from './gen-spec.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';

const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url));
Expand All @@ -17,14 +17,14 @@ export class OpenApiServerService {
}

@bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void): void {
fastify.get('/api-doc', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=86400');
return await reply.sendFile('/redoc.html', staticAssets);
});
fastify.get('/api.json', (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=600');
reply.send(genOpenapiSpec(this.config));
reply.send(genOpenApiSpec(this.config));
});
done();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/server/api/openapi/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Example } from './types';

export const errors = {
export const errors: Record<string, Record<string, Example>> = {
'400': {
'INVALID_PARAM': {
value: {
Expand Down
14 changes: 8 additions & 6 deletions packages/backend/src/server/api/openapi/gen-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { Config } from '@/config.js';
import endpoints from '../endpoints.js';
import { errors as basicErrors } from './errors.js';
import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
import type { OpenApiSpec, Operation, ValueOf } from './types.js';

export function genOpenapiSpec(config: Config) {
const spec = {
export function genOpenApiSpec(config: Config): OpenApiSpec {
const spec: OpenApiSpec = {
openapi: '3.0.0',

info: {
Expand All @@ -22,7 +23,7 @@ export function genOpenapiSpec(config: Config) {
url: config.apiUrl,
}],

paths: {} as any,
paths: {},

components: {
schemas: schemas,
Expand All @@ -38,7 +39,8 @@ export function genOpenapiSpec(config: Config) {
};

for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
const errors = {} as any;
type Error = ValueOf<NonNullable<typeof endpoint.meta.errors>>;
const errors: Record<Error['code'], { value: { error: Error } }> = {};

if (endpoint.meta.errors) {
for (const e of Object.values(endpoint.meta.errors)) {
Expand All @@ -60,7 +62,7 @@ export function genOpenapiSpec(config: Config) {
}

const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
const schema = { ...endpoint.params };
const schema = convertSchemaToOpenApiSchema(endpoint.params);

if (endpoint.meta.requireFile) {
schema.properties = {
Expand All @@ -74,7 +76,7 @@ export function genOpenapiSpec(config: Config) {
schema.required = [...schema.required ?? [], 'file'];
}

const info = {
const info: Operation = {
operationId: endpoint.name,
summary: endpoint.name,
description: desc,
Expand Down
18 changes: 10 additions & 8 deletions packages/backend/src/server/api/openapi/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Schema } from '@/misc/json-schema.js';
import { refs } from '@/misc/json-schema.js';
import { type Schema, refs } from '@/misc/json-schema.js';
import type { MediaType } from './types';

export function convertSchemaToOpenApiSchema(schema: Schema) {
const res: any = schema;
type OpenAPISchema = NonNullable<MediaType['schema']>;

export function convertSchemaToOpenApiSchema(schema: Schema): OpenAPISchema {
const res: OpenAPISchema = schema;

if (schema.type === 'object' && schema.properties) {
res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k);
res.required = Object.entries(schema.properties).filter(([, v]) => !v.optional).map(([k]) => k);

for (const k of Object.keys(schema.properties)) {
res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]);
for (const [k, v] of Object.entries(schema.properties)) {
res.properties[k] = convertSchemaToOpenApiSchema(v);
}
}

Expand All @@ -27,7 +29,7 @@ export function convertSchemaToOpenApiSchema(schema: Schema) {
return res;
}

export const schemas = {
export const schemas: { [key: string]: OpenAPISchema } = {
Error: {
type: 'object',
properties: {
Expand Down
252 changes: 252 additions & 0 deletions packages/backend/src/server/api/openapi/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
export type ValueOf<T> = T[keyof T];

export type Without<T extends object, U extends keyof T> = {
[K in keyof T]: K extends U ? never : T[K];
};

type FIXME = never;

/**
* OpenAPIのSpecの型定義
* @see https://raw.githubusercontent.com/OAI/OpenAPI-Specification/2408885/schemas/v3.0/schema.json
* @description とりあえずMisskeyにとって必要そうな部分に限って手動で書いたもの。JSON Schemaと対応した完全な型定義が欲しい場合は流石に何かしらのツールを使って自動生成することになるだろう
*/
export type OpenApiSpec = {
openapi: '3.0.0'; // '3.0.x'や'3.0.x-hoge'は考えないものとする
info: Info;
externalDocs?: ExternalDocumentation;
servers?: Server[];
security?: SecurityRequirement[];
tags?: Tag[];
paths: Paths;
components?: Components;
} & Extension;

// https://swagger.io/specification/#specification-extensions
type Extension = {
[ext: `x-${string}`]: unknown;
};

export type Info = {
title: string;
description?: string;
termsOfService?: string;
contact?: Contact;
license?: License;
version: string;
} & Extension;

type Contact = FIXME;

type License = FIXME;

export type ExternalDocumentation = {
description?: string;
url: string;
} & Extension;

export type Server = {
url: string;
description?: string;
variables?: {
[key: string]: ServerVariable;
};
} & Extension;

type ServerVariable = FIXME;

type SecurityRequirement = {
[key: string]: string[];
};

type Tag = FIXME;

export type Paths = {
[path: string]: PathItem; // `path`は/で始まる必要があるものの、それを型で定義すると流石に面倒なのでとりあえず省略
} & Extension;

export type PathItem = {
$ref?: string;
summary?: string;
description?: string;
servers?: Server[];
parameters?: (Parameter | Reference)[];
} & Partial<Record<OperationMethod, Operation>> &
Extension;

type Parameter = FIXME;

type Reference = {
$ref: string;
};

export type OperationMethod =
| 'get'
| 'put'
| 'post'
| 'delete'
| 'options'
| 'head'
| 'patch'
| 'trace';

export type Operation = {
tags?: string[];
summary?: string;
description?: string;
externalDocs?: ExternalDocumentation;
operationId?: string;
parameters?: (Parameter | Reference)[];
requestBody?: RequestBody | Reference;
responses: Responses;
callbacks?: {
[key: string]: Callback | Reference;
};
deprecated?: boolean;
security?: SecurityRequirement[];
servers?: Server[];
} & Extension;

type RequestBody = {
description?: string;
content: {
[key: string]: MediaType;
};
required?: boolean;
} & Extension;

export type MediaType = (
| Without<MediaTypeBase, 'example'>
| Without<MediaTypeBase, 'examples'>
) &
Extension;

type MediaTypeBase = {
schema?: Schema | Reference;
example?: unknown;
examples?: {
[key: string]: Example | Reference;
};
encoding?: {
[key: string]: Encoding;
};
};

type Schema = {
title?: string;
multipleOf?: number;
maximum?: number;
exclusiveMaximum?: boolean;
minimum?: number;
exclusiveMinimum?: boolean;
maxLength?: number;
minLength?: number;
pattern?: string;
maxItems?: number;
minItems?: number;
uniqueItems?: boolean;
maxProperties?: number;
minProperties?: number;
required?: string[];
enum?: unknown[];
type?: 'array' | 'boolean' | 'integer' | 'number' | 'object' | 'string';
not?: Schema | Reference;
allOf?: (Schema | Reference)[];
oneOf?: (Schema | Reference)[];
anyOf?: (Schema | Reference)[];
items?: Schema | Reference;
properties?: {
[key: string]: Schema | Reference;
};
additionalProperties?: Schema | Reference | boolean;
description?: string;
format?: string;
default?: unknown;
nullable?: boolean;
discriminator?: Discriminator;
readOnly?: boolean;
writeOnly?: boolean;
example?: unknown;
externalDocs?: ExternalDocumentation;
deprecated?: boolean;
xml?: XML;
} & Extension;

type Discriminator = FIXME;

type XML = FIXME;

export type Example = {
summary?: string;
description?: string;
value?: unknown;
externalValue?: string;
} & Extension;

type Encoding = FIXME;

type Responses = Response | Reference | { [key: string]: Response | Reference };

type Response = {
description: string;
headers?: Header | Reference;
content?: {
[key: string]: MediaType;
};
links?: Link | Reference;
};

type Header = FIXME;

type Link = FIXME;

type Callback = FIXME;

type Components = {
schemas?: {
[key: string]: Schema | Reference;
};
responses?: {
[key: string]: Reference | Response;
};
parameters?: {
[key: string]: Reference | Parameter;
};
examples?: {
[key: string]: Reference | Example;
};
requestBodies?: {
[key: string]: Reference | RequestBody;
};
headers?: {
[key: string]: Reference | Header;
};
securitySchemes?: {
[key: string]: Reference | SecurityScheme;
};
links?: {
[key: string]: Reference | Link;
};
callbacks?: {
[key: string]: Reference | Callback;
};
} & Extension;

type SecurityScheme =
| APIKeySecurityScheme
| HTTPSecurityScheme
| OAuth2SecurityScheme
| OpenIdConnectSecurityScheme;

type APIKeySecurityScheme = {
type: 'apiKey';
name: string;
in: 'header' | 'query' | 'cookie';
description?: string;
} & Extension;

type HTTPSecurityScheme = FIXME;

type OAuth2SecurityScheme = FIXME;

type OpenIdConnectSecurityScheme = FIXME;