From 4ebc4314bca7149750ebc197773191beba7270e9 Mon Sep 17 00:00:00 2001 From: sevenc-nanashi Date: Mon, 17 Mar 2025 23:40:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=AE=9F=E8=A1=8C=E6=99=82?= =?UTF-8?q?=E3=81=ABEngine=E3=81=AE=E5=9E=8B=E3=83=81=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=92=E8=A1=8C=E3=81=86=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 36 +++++++++++++++- src/store/proxy.ts | 103 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 7 deletions(-) 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.ts index 0873c974a1..74a11580c5 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy.ts @@ -1,3 +1,5 @@ +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; +import openapi from "../../openapi.json"; import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "./type"; import { createPartialStore } from "./vuex"; import { createEngineUrl } from "@/domain/url"; @@ -7,11 +9,100 @@ import { OpenAPIEngineAndMockConnectorFactory, OpenAPIEngineConnectorFactory, } from "@/infrastructures/EngineConnector"; -import { AudioQuery } from "@/openapi"; +import { AudioQuery, DefaultApiInterface } from "@/openapi"; import { EngineInfo } from "@/type/preload"; +import Ajv, { ValidateFunction } from "ajv"; export const proxyStoreState: ProxyStoreState = {}; +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 method of Object.values(path)) { + const schema = + method.responses["200"]?.content?.["application/json"]?.schema; + 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; + }; +} + +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) => toCamelCase(key)); + } + + for (const key in schema) { + if (typeof schema[key] === "object" && schema[key] != null) { + inner(schema[key] as Record); + } + } + return schema; + } +} + const proxyStoreCreator = (_engineFactory: IEngineConnectorFactory) => { const proxyStore = createPartialStore({ INSTANTIATE_ENGINE_CONNECTOR: { @@ -35,11 +126,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), + ), }); }, }, From 833a2dca6952e10383eea3debdca3855bfb610c2 Mon Sep 17 00:00:00 2001 From: sevenc-nanashi Date: Mon, 17 Mar 2025 23:42:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/proxy.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/store/proxy.ts b/src/store/proxy.ts index 74a11580c5..cad3ba6784 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy.ts @@ -64,6 +64,13 @@ function createValidateOpenApiResponse() { }; } +/** + * OpenAPIのスキーマを修正する。 + * + * 具体的には以下の変更を行う: + * - `$ref`の参照先を`#/components/schemas/`から`openapi.json#/definitions/`に変更する + * - オブジェクトのプロパティ名をキャメルケースに変換する + */ function patchOpenApiJson>(schema: T): T { return inner(cloneWithUnwrapProxy(schema)) as T; From b8ccc944803b6dabe4e25b73d732b097e6d5c86c Mon Sep 17 00:00:00 2001 From: sevenc-nanashi Date: Tue, 18 Mar 2025 12:00:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?style:=20=E3=82=A4=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E4=BD=8D=E7=BD=AE=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/proxy.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/store/proxy.ts b/src/store/proxy.ts index cad3ba6784..63d7f15219 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy.ts @@ -1,7 +1,8 @@ -import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; +import Ajv, { JSONSchemaType, ValidateFunction } from "ajv"; import openapi from "../../openapi.json"; import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "./type"; import { createPartialStore } from "./vuex"; +import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; import { createEngineUrl } from "@/domain/url"; import { isElectron, isProduction } from "@/helpers/platform"; import { @@ -11,7 +12,6 @@ import { } from "@/infrastructures/EngineConnector"; import { AudioQuery, DefaultApiInterface } from "@/openapi"; import { EngineInfo } from "@/type/preload"; -import Ajv, { ValidateFunction } from "ajv"; export const proxyStoreState: ProxyStoreState = {}; @@ -30,9 +30,16 @@ function createValidateOpenApiResponse() { const validatorCache = new Map(); for (const path of Object.values(openapi.paths)) { - for (const method of Object.values(path)) { - const schema = - method.responses["200"]?.content?.["application/json"]?.schema; + 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; } @@ -98,7 +105,9 @@ function patchOpenApiJson>(schema: T): T { ]), ); - schema["required"] = schema["required"].map((key) => toCamelCase(key)); + schema["required"] = schema["required"].map((key: string) => + toCamelCase(key), + ); } for (const key in schema) { From 1b77b4a219215e8944e2f87c0f01df9cfeeb1aa3 Mon Sep 17 00:00:00 2001 From: sevenc-nanashi Date: Tue, 18 Mar 2025 12:01:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20openapi.ts=E3=81=AB=E5=88=87?= =?UTF-8?q?=E3=82=8A=E5=87=BA=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/proxy/index.ts | 82 ++++++++++++++++++++++ src/store/{proxy.ts => proxy/openapi.ts} | 86 +----------------------- 2 files changed, 85 insertions(+), 83 deletions(-) create mode 100644 src/store/proxy/index.ts rename src/store/{proxy.ts => proxy/openapi.ts} (52%) diff --git a/src/store/proxy/index.ts b/src/store/proxy/index.ts new file mode 100644 index 0000000000..a492b85475 --- /dev/null +++ b/src/store/proxy/index.ts @@ -0,0 +1,82 @@ +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 { + IEngineConnectorFactory, + OpenAPIEngineAndMockConnectorFactory, + OpenAPIEngineConnectorFactory, +} from "@/infrastructures/EngineConnector"; +import { AudioQuery } from "@/openapi"; +import { EngineInfo } from "@/type/preload"; + +export const proxyStoreState: ProxyStoreState = {}; + +const proxyStoreCreator = (_engineFactory: IEngineConnectorFactory) => { + const proxyStore = createPartialStore({ + INSTANTIATE_ENGINE_CONNECTOR: { + action({ state }, payload) { + const engineId = payload.engineId; + const engineInfo: EngineInfo | undefined = state.engineInfos[engineId]; + if (engineInfo == undefined) + return Promise.reject( + new Error(`No such engineInfo registered: engineId == ${engineId}`), + ); + + const altPort: string | undefined = state.altPortInfos[engineId]; + const port = altPort ?? engineInfo.defaultPort; + const instance = _engineFactory.instance( + createEngineUrl({ + protocol: engineInfo.protocol, + hostname: engineInfo.hostname, + port, + pathname: engineInfo.pathname, + }), + ); + return Promise.resolve({ + invoke: (v) => (arg) => + validateOpenApiResponse( + v, + // @ts-expect-error 動いているので無視 + instance[v](arg), + ), + }); + }, + }, + }); + return proxyStore; +}; + +/** AudioQueryをエンジン用に変換する */ +export const convertAudioQueryFromEditorToEngine = ( + editorAudioQuery: EditorAudioQuery, + defaultOutputSamplingRate: number, +): AudioQuery => { + return { + ...editorAudioQuery, + outputSamplingRate: + editorAudioQuery.outputSamplingRate == "engineDefault" + ? defaultOutputSamplingRate + : editorAudioQuery.outputSamplingRate, + }; +}; + +/** AudioQueryをエディタ用に変換する */ +export const convertAudioQueryFromEngineToEditor = ( + engineAudioQuery: AudioQuery, +): EditorAudioQuery => { + return { + ...engineAudioQuery, + pauseLengthScale: engineAudioQuery.pauseLengthScale ?? 1, + }; +}; + +// 製品PC版は通常エンジンのみを、それ以外はモックエンジンも使えるようする +const getConnectorFactory = () => { + if (isElectron && isProduction) { + return OpenAPIEngineConnectorFactory; + } + return OpenAPIEngineAndMockConnectorFactory; +}; +export const proxyStore = proxyStoreCreator(getConnectorFactory()); diff --git a/src/store/proxy.ts b/src/store/proxy/openapi.ts similarity index 52% rename from src/store/proxy.ts rename to src/store/proxy/openapi.ts index 63d7f15219..19e89362c9 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy/openapi.ts @@ -1,21 +1,9 @@ import Ajv, { JSONSchemaType, ValidateFunction } from "ajv"; -import openapi from "../../openapi.json"; -import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "./type"; -import { createPartialStore } from "./vuex"; +import openapi from "../../../openapi.json"; import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy"; -import { createEngineUrl } from "@/domain/url"; -import { isElectron, isProduction } from "@/helpers/platform"; -import { - IEngineConnectorFactory, - OpenAPIEngineAndMockConnectorFactory, - OpenAPIEngineConnectorFactory, -} from "@/infrastructures/EngineConnector"; -import { AudioQuery, DefaultApiInterface } from "@/openapi"; -import { EngineInfo } from "@/type/preload"; +import { DefaultApiInterface } from "@/openapi"; -export const proxyStoreState: ProxyStoreState = {}; - -const validateOpenApiResponse = createValidateOpenApiResponse(); +export const validateOpenApiResponse = createValidateOpenApiResponse(); function toCamelCase(str: string) { return str.replace(/_./g, (s) => s.charAt(1).toUpperCase()); @@ -118,71 +106,3 @@ function patchOpenApiJson>(schema: T): T { return schema; } } - -const proxyStoreCreator = (_engineFactory: IEngineConnectorFactory) => { - const proxyStore = createPartialStore({ - INSTANTIATE_ENGINE_CONNECTOR: { - action({ state }, payload) { - const engineId = payload.engineId; - const engineInfo: EngineInfo | undefined = state.engineInfos[engineId]; - if (engineInfo == undefined) - return Promise.reject( - new Error(`No such engineInfo registered: engineId == ${engineId}`), - ); - - const altPort: string | undefined = state.altPortInfos[engineId]; - const port = altPort ?? engineInfo.defaultPort; - const instance = _engineFactory.instance( - createEngineUrl({ - protocol: engineInfo.protocol, - hostname: engineInfo.hostname, - port, - pathname: engineInfo.pathname, - }), - ); - return Promise.resolve({ - invoke: (v) => (arg) => - validateOpenApiResponse( - v, - // @ts-expect-error 動いているので無視 - instance[v](arg), - ), - }); - }, - }, - }); - return proxyStore; -}; - -/** AudioQueryをエンジン用に変換する */ -export const convertAudioQueryFromEditorToEngine = ( - editorAudioQuery: EditorAudioQuery, - defaultOutputSamplingRate: number, -): AudioQuery => { - return { - ...editorAudioQuery, - outputSamplingRate: - editorAudioQuery.outputSamplingRate == "engineDefault" - ? defaultOutputSamplingRate - : editorAudioQuery.outputSamplingRate, - }; -}; - -/** AudioQueryをエディタ用に変換する */ -export const convertAudioQueryFromEngineToEditor = ( - engineAudioQuery: AudioQuery, -): EditorAudioQuery => { - return { - ...engineAudioQuery, - pauseLengthScale: engineAudioQuery.pauseLengthScale ?? 1, - }; -}; - -// 製品PC版は通常エンジンのみを、それ以外はモックエンジンも使えるようする -const getConnectorFactory = () => { - if (isElectron && isProduction) { - return OpenAPIEngineConnectorFactory; - } - return OpenAPIEngineAndMockConnectorFactory; -}; -export const proxyStore = proxyStoreCreator(getConnectorFactory());