diff --git a/package.json b/package.json index 84ca51c..d427470 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.1", + "version": "2.0.2-rc.6", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -59,12 +59,13 @@ }, "license": "BSD-3-Clause", "dependencies": { + "i18next": "^25.7.3", "import-fresh": "^3.3.1" }, "peerDependencies": { "@casl/ability": "^6.7.3", "@casl/react": "^5.0.0", - "@tanstack/react-query": "^5.85.9", + "@tanstack/react-query": "^5.89.0", "axios": "^1.13.1", "react": "^19.1.0", "zod": "^4.1.12" diff --git a/src/generators/core/zod/getZodSchema.test.ts b/src/generators/core/zod/getZodSchema.test.ts index 051df61..20221ee 100644 --- a/src/generators/core/zod/getZodSchema.test.ts +++ b/src/generators/core/zod/getZodSchema.test.ts @@ -267,7 +267,7 @@ describe("getZodSchema", () => { discriminator: { propertyName: "type" }, }), ).toStrictEqual( - 'z.union([z.object({ type: z.enum(["a"]), a: z.string() }).merge(z.object({ type: z.enum(["c"]), c: z.string() })), z.object({ type: z.enum(["b"]), b: z.string() }).merge(z.object({ type: z.enum(["d"]), d: z.string() }))])', + 'z.union([z.object({ ...z.object({ type: z.enum(["a"]), a: z.string() }).shape, ...z.object({ type: z.enum(["c"]), c: z.string() }).shape }), z.object({ ...z.object({ type: z.enum(["b"]), b: z.string() }).shape, ...z.object({ type: z.enum(["d"]), d: z.string() }).shape })])', ); expect( @@ -286,7 +286,7 @@ describe("getZodSchema", () => { intersection: { allOf: [{ type: "string" }, { type: "number" }] }, }, }), - ).toStrictEqual("z.object({ intersection: z.string().merge(z.number()) }).partial()"); + ).toStrictEqual("z.object({ intersection: z.object({ ...z.string().shape, ...z.number().shape }) }).partial()"); expect(getZodSchemaString({ type: "string", enum: ["aaa", "bbb", "ccc"] })).toStrictEqual( 'z.enum(["aaa", "bbb", "ccc"])', diff --git a/src/generators/core/zod/getZodSchema.ts b/src/generators/core/zod/getZodSchema.ts index aa409dc..e215c18 100644 --- a/src/generators/core/zod/getZodSchema.ts +++ b/src/generators/core/zod/getZodSchema.ts @@ -307,10 +307,10 @@ function getAllOfZodSchema({ schema, zodSchema, resolver, meta, tag }: GetPartia const first = types.at(0)!; const rest = types .slice(1) - .map((type) => `merge(${type.getCodeString(tag, resolver.options)})`) - .join("."); + .map((type) => `...${type.getCodeString(tag, resolver.options)}.shape`) + .join(", "); - return zodSchema.assign(`${first.getCodeString(tag, resolver.options)}.${rest}`); + return zodSchema.assign(`z.object({ ...${first.getCodeString(tag, resolver.options)}.shape, ${rest} })`); } function getPrimitiveZodSchema({ schema, zodSchema, resolver, meta, tag }: GetPartialZodSchemaParams) { diff --git a/src/generators/templates/partials/query-use-mutation.hbs b/src/generators/templates/partials/query-use-mutation.hbs index 89ac3fd..9b1c7dc 100644 --- a/src/generators/templates/partials/query-use-mutation.hbs +++ b/src/generators/templates/partials/query-use-mutation.hbs @@ -10,8 +10,8 @@ export const {{queryName endpoint mutation=true}} = (options?: AppMutationOption return {{queryHook}}({ mutationFn: {{#if endpoint.mediaUpload}}async {{/if}}({{#if (endpointParams endpoint includeFileParam=true)}} { {{{endpointArgs endpoint includeFileParam=true}}}{{#if endpoint.mediaUpload}}, abortController, onUploadProgress{{/if}} } {{/if}}) => {{#if hasMutationFnBody}} { {{/if}} {{#if hasAclCheck}}{{{genAclCheckCall endpoint}}}{{/if}} - {{#if endpoint.mediaUpload}}const uploadInstructions = await {{importedEndpointName endpoint}}({{{endpointArgs endpoint}}}{{#if hasAxiosRequestConfig}}{{#if (endpointArgs endpoint)}}, {{/if}}{{axiosRequestConfigName}}{{/if}}); - + {{#if endpoint.mediaUpload}}const uploadInstructions = await {{importedEndpointName endpoint}}({{{endpointArgs endpoint}}}{{#if hasAxiosRequestConfig}}{{#if (endpointArgs endpoint)}}, {{/if}}{{axiosRequestConfigName}}{{/if}}); + if (file && uploadInstructions.url) { const method = (data?.method?.toLowerCase() ?? "put") as 'put' | 'post'; let dataToSend: File | FormData = file; @@ -34,21 +34,21 @@ export const {{queryName endpoint mutation=true}} = (options?: AppMutationOption : undefined, }); } - + return uploadInstructions; {{else}} {{#if hasMutationFnBody}}return {{/if}}{{importedEndpointName endpoint}}({{{endpointArgs endpoint}}}{{#if hasAxiosRequestConfig}}{{#if (endpointArgs endpoint)}}, {{/if}}{{axiosRequestConfigName}}{{/if}}) {{/if}} {{#if hasMutationFnBody}} }{{/if}}, ...options, {{#if hasMutationEffects}} - onSuccess: async (resData, variables, context) => { + onSuccess: async (resData, variables, onMutateResult, context) => { {{! Mutation effects }} {{#if updateQueryEndpoints}} {{#if destructuredVariables}}const { {{commaSeparated destructuredVariables }} } = variables;{{/if}} const updateKeys = [{{#each updateQueryEndpoints as | endpoint |}}keys.{{endpointName endpoint}}({{{endpointArgs endpoint includeOnlyRequiredParams=true}}}), {{/each}}]; {{/if}} await runMutationEffects(resData, options{{#if updateQueryEndpoints}}, updateKeys{{/if}}); - options?.onSuccess?.(resData, variables, context); + options?.onSuccess?.(resData, variables, onMutateResult, context); },{{/if}} }); -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 4728469..acb8ac6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,9 @@ export type { AppQueryOptions, AppMutationOptions, AppInfiniteQueryOptions } fro export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; +// i18n resources (for consumer apps to merge into their i18n config) +export { ns, resources } from "./lib/config/i18n"; + // Auth export { AuthContext } from "./lib/auth/auth.context"; export { AuthGuard } from "./lib/auth/AuthGuard"; diff --git a/src/lib/assets/locales/en/translation.json b/src/lib/assets/locales/en/translation.json new file mode 100644 index 0000000..716738a --- /dev/null +++ b/src/lib/assets/locales/en/translation.json @@ -0,0 +1,12 @@ +{ + "openapi": { + "sharedErrors": { + "dataValidation": "An error occurred while validating the data", + "internalError": "An internal error occurred. This is most likely a bug on our end. Please try again later.", + "networkError": "A network error occurred. Are you connected to the internet?", + "canceledError": "The request was canceled.", + "unknownError": "An unknown error occurred. Please try again later.", + "unknownErrorWithCode": "An unknown error occurred. Error code: \"{{code}}\"" + } + } +} diff --git a/src/lib/assets/locales/sl/translation.json b/src/lib/assets/locales/sl/translation.json new file mode 100644 index 0000000..f00c3dd --- /dev/null +++ b/src/lib/assets/locales/sl/translation.json @@ -0,0 +1,12 @@ +{ + "openapi": { + "sharedErrors": { + "dataValidation": "Pri preverjanju podatkov je prišlo do napake", + "internalError": "Prišlo je do notranje napake.", + "networkError": "Prišlo je do napake v omrežju.", + "canceledError": "Zahteva je bila preklicana.", + "unknownError": "Prišlo je do neznane napake.", + "unknownErrorWithCode": "Prišlo je do neznane napake. Koda napake: \"{{code}}\"" + } + } +} diff --git a/src/lib/config/i18n.ts b/src/lib/config/i18n.ts new file mode 100644 index 0000000..b216926 --- /dev/null +++ b/src/lib/config/i18n.ts @@ -0,0 +1,31 @@ +import i18next from "i18next"; + +import translationEN from "src/lib/assets/locales/en/translation.json"; +import translationSL from "src/lib/assets/locales/sl/translation.json"; + +export const ns = "openapi"; +export const resources = { + en: { + [ns]: translationEN, + }, + sl: { + [ns]: translationSL, + }, +} as const; + +const defaultLanguage = "en"; + +const i18n = i18next.createInstance(); +i18n.init({ + compatibilityJSON: "v4", + lng: defaultLanguage, + fallbackLng: defaultLanguage, + resources, + ns: Object.keys(resources.en), + defaultNS: ns, + interpolation: { + escapeValue: false, + }, +}); + +export const defaultT = i18n.t.bind(i18n); diff --git a/src/lib/rest/error-handling.ts b/src/lib/rest/error-handling.ts index 0958413..7ca6c26 100644 --- a/src/lib/rest/error-handling.ts +++ b/src/lib/rest/error-handling.ts @@ -1,9 +1,10 @@ import axios from "axios"; +import { type TFunction } from "i18next"; import { z } from "zod"; +import { defaultT } from "src/lib/config/i18n"; import { RestUtils } from "./rest.utils"; -// codes that we want to handle in every scenario export type GeneralErrorCodes = | "DATA_VALIDATION_ERROR" | "NETWORK_ERROR" @@ -26,31 +27,32 @@ export class ApplicationException extends Error { export interface ErrorEntry { code: CodeT; - condition: (error: unknown) => boolean; - getMessage: (error: unknown) => string; + condition?: (error: unknown) => boolean; + getMessage: (t: TFunction, error: unknown) => string; } export interface ErrorHandlerOptions { entries: ErrorEntry[]; + t?: TFunction; onRethrowError?: (error: unknown, exception: ApplicationException) => void; } export class ErrorHandler { entries: ErrorEntry[] = []; + private t: TFunction; private onRethrowError?: (error: unknown, exception: ApplicationException) => void; - constructor({ entries, onRethrowError }: ErrorHandlerOptions) { + constructor({ entries, t = defaultT, onRethrowError }: ErrorHandlerOptions) { + this.t = t; this.onRethrowError = onRethrowError; type ICodeT = CodeT | GeneralErrorCodes; - // implement checking for each of the general errors - const dataValidationError: ErrorEntry = { code: "DATA_VALIDATION_ERROR", condition: (e) => { return e instanceof z.ZodError; }, - getMessage: () => "An error occurred while validating the data", + getMessage: () => this.t("openapi.sharedErrors.dataValidation"), }; const internalError: ErrorEntry = { @@ -62,7 +64,7 @@ export class ErrorHandler { return false; }, - getMessage: () => "An internal error occurred. This is most likely a bug on our end. Please try again later.", + getMessage: () => this.t("openapi.sharedErrors.internalError"), }; const networkError: ErrorEntry = { @@ -74,7 +76,7 @@ export class ErrorHandler { return false; }, - getMessage: () => "A network error occurred. Are you connected to the internet?", + getMessage: () => this.t("openapi.sharedErrors.networkError"), }; const canceledError: ErrorEntry = { @@ -90,25 +92,49 @@ export class ErrorHandler { return false; }, - getMessage: () => "The request was canceled.", + getMessage: () => this.t("openapi.sharedErrors.canceledError"), }; const unknownError: ErrorEntry = { code: "UNKNOWN_ERROR", condition: () => true, - getMessage: () => "An unknown error occurred. Please try again later.", + getMessage: (_, e) => { + const code = RestUtils.extractServerResponseCode(e); + const serverMessage = RestUtils.extractServerErrorMessage(e); + + if (code) { + let message = `Unknown error, message from server: ${code}`; + if (serverMessage) { + message += ` ${serverMessage}`; + } + return message; + } + + return this.t("openapi.sharedErrors.unknownError"); + }, }; // general errors have the lowest priority this.entries = [...entries, dataValidationError, internalError, networkError, canceledError, unknownError]; } - // convert the error into an application exception + private matchesEntry(error: unknown, entry: ErrorEntry, code: string | null): boolean { + if (entry.condition) { + return entry.condition(error); + } + return code === entry.code; + } + + public setTranslateFunction(t: TFunction) { + this.t = t; + } + public rethrowError(error: unknown): ApplicationException { - const errorEntry = this.entries.find((entry) => entry.condition(error ?? {}))!; + const code = RestUtils.extractServerResponseCode(error); + const errorEntry = this.entries.find((entry) => this.matchesEntry(error, entry, code))!; const serverMessage = RestUtils.extractServerErrorMessage(error); - const exception = new ApplicationException(errorEntry.getMessage(error), errorEntry.code, serverMessage); + const exception = new ApplicationException(errorEntry.getMessage(this.t, error), errorEntry.code, serverMessage); this.onRethrowError?.(error, exception); @@ -145,7 +171,7 @@ export class ErrorHandler { } if (fallbackToUnknown) { - return "An unknown error occurred. Please try again later."; + return defaultT("openapi.sharedErrors.unknownError"); } return null; diff --git a/yarn.lock b/yarn.lock index 47f6a9d..f923fb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -57,6 +57,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/runtime@npm:7.28.4" + checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 + languageName: node + linkType: hard + "@casl/ability@npm:^6.7.3": version: 6.7.5 resolution: "@casl/ability@npm:6.7.5" @@ -651,6 +658,7 @@ __metadata: eslint-plugin-no-relative-import-paths: "npm:^1.6.1" eslint-plugin-prettier: "npm:^5.1.3" handlebars: "npm:^4.7.8" + i18next: "npm:^25.7.3" import-fresh: "npm:^3.3.1" openapi-types: "npm:^12.1.3" prettier: "npm:^3.2.5" @@ -668,7 +676,7 @@ __metadata: peerDependencies: "@casl/ability": ^6.7.3 "@casl/react": ^5.0.0 - "@tanstack/react-query": ^5.85.9 + "@tanstack/react-query": ^5.89.0 axios: ^1.13.1 react: ^19.1.0 zod: ^4.1.12 @@ -2507,6 +2515,20 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^25.7.3": + version: 25.7.4 + resolution: "i18next@npm:25.7.4" + dependencies: + "@babel/runtime": "npm:^7.28.4" + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/d30e4f9a1c31adc0570c9f4a22ab226257620f2465e2275f8cdd5ef7f52668716cc9be3b741031aeacb39e2196be5a5f656e12d89787b4248098f681e99f72ea + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3"