diff --git a/package.json b/package.json index 4f51ac454b..d43ed7cae3 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@quasar/extras": "1.16.17", "@sevenc-nanashi/utaformatix-ts": "npm:@jsr/sevenc-nanashi__utaformatix-ts@0.4.0", "@std/path": "npm:@jsr/std__path@1.0.8", + "ajv": "8.17.1", "async-lock": "1.4.1", "dayjs": "1.11.13", "electron-log": "5.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea4442edf..e655f3343e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@std/path': specifier: npm:@jsr/std__path@1.0.8 version: '@jsr/std__path@1.0.8' + ajv: + specifier: 8.17.1 + version: 8.17.1 async-lock: specifier: 1.4.1 version: 1.4.1 @@ -1841,6 +1844,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + alien-signals@0.4.14: resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} @@ -2781,6 +2787,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.0.6: + resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} @@ -3371,6 +3380,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4248,6 +4260,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -5135,6 +5151,9 @@ packages: vue-component-type-helpers@2.2.8: resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==} + vue-component-type-helpers@3.0.0-alpha.2: + resolution: {integrity: sha512-dv9YzsuJFLnpRNxKU0exwIlCIA/v+rXrgCsEtaENsFJLPFMw1Sr4IRctilwfjnjCzoJGgGACHRZfxo6ZwlH2fQ==} + vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -6401,7 +6420,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.13(typescript@5.8.2) - vue-component-type-helpers: 2.2.8 + vue-component-type-helpers: 3.0.0-alpha.2 '@swc/helpers@0.5.15': dependencies: @@ -6939,6 +6958,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@0.4.14: {} alien-signals@1.0.4: {} @@ -8112,6 +8138,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.0.6: {} + fastq@1.18.0: dependencies: reusify: 1.0.4 @@ -8734,6 +8762,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -9847,6 +9877,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} resedit@1.7.2: @@ -10727,6 +10759,8 @@ snapshots: vue-component-type-helpers@2.2.8: {} + vue-component-type-helpers@3.0.0-alpha.2: {} + vue-demi@0.14.10(vue@3.5.13(typescript@5.8.2)): dependencies: vue: 3.5.13(typescript@5.8.2) diff --git a/src/store/proxy.ts b/src/store/proxy/index.ts similarity index 87% rename from src/store/proxy.ts rename to src/store/proxy/index.ts index 0873c974a1..a492b85475 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy/index.ts @@ -1,5 +1,6 @@ -import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "./type"; -import { createPartialStore } from "./vuex"; +import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "../type"; +import { createPartialStore } from "../vuex"; +import { validateOpenApiResponse } from "./openapi"; import { createEngineUrl } from "@/domain/url"; import { isElectron, isProduction } from "@/helpers/platform"; import { @@ -35,11 +36,11 @@ const proxyStoreCreator = (_engineFactory: IEngineConnectorFactory) => { ); return Promise.resolve({ invoke: (v) => (arg) => - // FIXME: anyを使わないようにする - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - instance[v](arg) as any, + validateOpenApiResponse( + v, + // @ts-expect-error 動いているので無視 + instance[v](arg), + ), }); }, }, diff --git a/src/store/proxy/openapi.ts b/src/store/proxy/openapi.ts new file mode 100644 index 0000000000..19e89362c9 --- /dev/null +++ b/src/store/proxy/openapi.ts @@ -0,0 +1,108 @@ +import Ajv, { JSONSchemaType, ValidateFunction } from "ajv"; +import openapi from "../../../openapi.json"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; +import { DefaultApiInterface } from "@/openapi"; + +export const validateOpenApiResponse = createValidateOpenApiResponse(); + +function toCamelCase(str: string) { + return str.replace(/_./g, (s) => s.charAt(1).toUpperCase()); +} + +/** OpenAPIのレスポンスを検証する */ +function createValidateOpenApiResponse() { + const ajv = new Ajv().addSchema({ + $id: "openapi.json", + definitions: patchOpenApiJson(openapi).components.schemas, + }); + const validatorCache = new Map(); + + for (const path of Object.values(openapi.paths)) { + for (const rawMethod of Object.values(path)) { + const method = rawMethod as { + operationId: string; + responses: Record< + string, + { content?: Record } + >; + }; + const schema = method.responses["200"]?.content?.["application/json"] + ?.schema as JSONSchemaType; + if (schema == null) { + continue; + } + validatorCache.set( + toCamelCase(method.operationId), + ajv.compile(patchOpenApiJson(schema)), + ); + } + } + + return ( + key: K, + response: ReturnType, + ): ReturnType => { + return response.then((res) => { + const maybeValidator = validatorCache.get(key); + if (maybeValidator == null) { + return res; + } + + if (!maybeValidator(res)) { + throw new Error( + `Response validation error in ${key}: ${ajv.errorsText(maybeValidator.errors)}`, + ); + } + + return res; + }) as ReturnType; + }; +} + +/** + * OpenAPIのスキーマを修正する。 + * + * 具体的には以下の変更を行う: + * - `$ref`の参照先を`#/components/schemas/`から`openapi.json#/definitions/`に変更する + * - オブジェクトのプロパティ名をキャメルケースに変換する + */ +function patchOpenApiJson>(schema: T): T { + return inner(cloneWithUnwrapProxy(schema)) as T; + + function inner(schema: Record): Record { + if (schema["$ref"] != null) { + const ref = schema["$ref"]; + if (typeof ref === "string") { + schema["$ref"] = ref.replace( + "#/components/schemas/", + "openapi.json#/definitions/", + ); + } + } + + if ( + schema["type"] === "object" && + typeof schema["properties"] === "object" && + schema["properties"] != null && + Array.isArray(schema["required"]) + ) { + schema["properties"] = Object.fromEntries( + Object.entries(schema["properties"]).map(([key, value]) => [ + toCamelCase(key), + inner(value as Record), + ]), + ); + + schema["required"] = schema["required"].map((key: string) => + toCamelCase(key), + ); + } + + for (const key in schema) { + if (typeof schema[key] === "object" && schema[key] != null) { + inner(schema[key] as Record); + } + } + return schema; + } +}