From 613e0eac176b5f48f075b6c2e03b383f9d600d7c Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 3 Jan 2025 15:30:13 +0700 Subject: [PATCH 1/5] config and tests --- packages/contract/src/config.test-d.ts | 19 +++++++++ packages/contract/src/config.test.ts | 20 +++++++++ packages/contract/src/config.ts | 58 ++++++++++++++++++++++++++ packages/contract/src/index.ts | 1 + packages/contract/src/procedure.ts | 6 ++- packages/contract/src/types.ts | 2 + 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages/contract/src/config.test-d.ts create mode 100644 packages/contract/src/config.test.ts create mode 100644 packages/contract/src/config.ts diff --git a/packages/contract/src/config.test-d.ts b/packages/contract/src/config.test-d.ts new file mode 100644 index 00000000..a5dbeec2 --- /dev/null +++ b/packages/contract/src/config.test-d.ts @@ -0,0 +1,19 @@ +import { configGlobal, fallbackToGlobalConfig } from './config' + +it('configGlobal & fallbackToGlobalConfig', () => { + configGlobal({ + defaultMethod: 'GET', + }) + + configGlobal({ + // @ts-expect-error -- invalid value + defaultMethod: 'INVALID', + }) + + fallbackToGlobalConfig('defaultMethod', undefined) + fallbackToGlobalConfig('defaultMethod', 'GET') + // @ts-expect-error -- invalid value + fallbackToGlobalConfig('defaultMethod', 'INVALID') + // @ts-expect-error -- invalid global config + fallbackToGlobalConfig('INVALID', 'GET') +}) diff --git a/packages/contract/src/config.test.ts b/packages/contract/src/config.test.ts new file mode 100644 index 00000000..d18db2bf --- /dev/null +++ b/packages/contract/src/config.test.ts @@ -0,0 +1,20 @@ +import { configGlobal, fallbackToGlobalConfig } from './config' + +it('configGlobal & fallbackToGlobalConfig', () => { + configGlobal({ + defaultMethod: 'GET', + }) + + expect(fallbackToGlobalConfig('defaultMethod', undefined)).toBe('GET') + + configGlobal({ + defaultMethod: undefined, + }) + + expect(fallbackToGlobalConfig('defaultMethod', undefined)).toBe('POST') + + expect(fallbackToGlobalConfig('defaultMethod', 'DELETE')).toBe('DELETE') + + expect(fallbackToGlobalConfig('defaultInputStructure', undefined)).toBe('compact') + expect(fallbackToGlobalConfig('defaultInputStructure', 'detailed')).toBe('detailed') +}) diff --git a/packages/contract/src/config.ts b/packages/contract/src/config.ts new file mode 100644 index 00000000..b94a74ed --- /dev/null +++ b/packages/contract/src/config.ts @@ -0,0 +1,58 @@ +import type { HTTPMethod, InputStructure } from './types' + +export interface ORPCConfig { + /** + * @default 'POST' + */ + defaultMethod?: HTTPMethod + + /** + * + * @default 200 + */ + defaultSuccessStatus?: number + + /** + * + * @default 'compact' + */ + defaultInputStructure?: InputStructure + + /** + * + * @default 'compact' + */ + defaultOutputStructure?: InputStructure +} + +const DEFAULT_CONFIG: Required = { + defaultMethod: 'POST', + defaultSuccessStatus: 200, + defaultInputStructure: 'compact', + defaultOutputStructure: 'compact', +} + +const GLOBAL_CONFIG: Required = { + ...DEFAULT_CONFIG, +} + +/** + * Set the global configuration, this configuration can effect entire project + * If value is undefined, it will use the default value + */ +export function configGlobal(config: ORPCConfig): void { + for (const [key, value] of Object.entries(config)) { + Reflect.set(GLOBAL_CONFIG, key, value !== undefined ? value : DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]) + } +} + +/** + * Fallback the value to the global config if it is undefined + */ +export function fallbackToGlobalConfig(key: T, value: ORPCConfig[T]): Exclude { + if (value === undefined) { + return GLOBAL_CONFIG[key] as any + } + + return value as any +} diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index d28623f4..0d216399 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -3,6 +3,7 @@ import { ContractBuilder } from './builder' export * from './builder' +export * from './config' export * from './procedure' export * from './procedure-decorated' export * from './router' diff --git a/packages/contract/src/procedure.ts b/packages/contract/src/procedure.ts index ef5b6b62..39daad96 100644 --- a/packages/contract/src/procedure.ts +++ b/packages/contract/src/procedure.ts @@ -1,6 +1,8 @@ import type { HTTPMethod, HTTPPath, + InputStructure, + OutputStructure, Schema, SchemaOutput, } from './types' @@ -41,7 +43,7 @@ export interface RouteOptions { * * @default 'compact' */ - inputStructure?: 'compact' | 'detailed' + inputStructure?: InputStructure /** * Determines how the response should be structured based on the output. @@ -64,7 +66,7 @@ export interface RouteOptions { * * @default 'compact' */ - outputStructure?: 'compact' | 'detailed' + outputStructure?: OutputStructure } export interface ContractProcedureDef { diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 6b1dfd56..caa9bd45 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -2,6 +2,8 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' export type HTTPPath = `/${string}` export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' +export type InputStructure = 'compact' | 'detailed' +export type OutputStructure = 'compact' | 'detailed' export type Schema = StandardSchemaV1 | undefined From 673dc0d8202ea1b3759aac2c47ca0251b623464a Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 3 Jan 2025 15:45:45 +0700 Subject: [PATCH 2/5] use global config --- .../src/adapters/fetch/openapi-handler.ts | 16 ++++++---------- .../adapters/fetch/openapi-procedure-matcher.ts | 4 ++-- packages/openapi/src/openapi-generator.ts | 12 +++++++----- .../src/openapi-input-structure-parser.ts | 4 ++-- packages/server/src/index.ts | 2 ++ 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.ts b/packages/openapi/src/adapters/fetch/openapi-handler.ts index 40b22793..e4465a94 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.ts @@ -2,7 +2,7 @@ import type { ANY_PROCEDURE, Context, Router, WithSignal } from '@orpc/server' import type { ConditionalFetchHandler, FetchOptions } from '@orpc/server/fetch' import type { Params } from 'hono/router' import type { PublicInputStructureCompact } from './input-structure-compact' -import { createProcedureClient, ORPCError } from '@orpc/server' +import { createProcedureClient, fallbackToGlobalConfig, ORPCError } from '@orpc/server' import { executeWithHooks, type Hooks, isPlainObject, ORPC_HANDLER_HEADER, trim } from '@orpc/shared' import { JSONSerializer, type PublicJSONSerializer } from '../../json-serializer' import { InputStructureCompact } from './input-structure-compact' @@ -86,7 +86,7 @@ export class OpenAPIHandler implements ConditionalFetchHandle return new Response(body, { headers: resHeaders, - status: contractDef.route?.successStatus ?? 200, + status: fallbackToGlobalConfig('defaultSuccessStatus', contractDef.route?.successStatus), }) } @@ -128,13 +128,13 @@ export class OpenAPIHandler implements ConditionalFetchHandle } private async decodeInput(procedure: ANY_PROCEDURE, params: Params, request: Request): Promise { - const inputStructure = procedure['~orpc'].contract['~orpc'].route?.inputStructure + const inputStructure = fallbackToGlobalConfig('defaultInputStructure', procedure['~orpc'].contract['~orpc'].route?.inputStructure) const url = new URL(request.url) const query = url.searchParams const headers = request.headers - if (!inputStructure || inputStructure === 'compact') { + if (inputStructure === 'compact') { return this.inputStructureCompact.build( params, request.method === 'GET' @@ -143,8 +143,6 @@ export class OpenAPIHandler implements ConditionalFetchHandle ) } - const _expect: 'detailed' = inputStructure - const decodedQuery = await this.payloadCodec.decode(query) const decodedHeaders = await this.payloadCodec.decode(headers) const decodedBody = await this.payloadCodec.decode(request) @@ -157,14 +155,12 @@ export class OpenAPIHandler implements ConditionalFetchHandle output: unknown, accept: string | undefined, ): { body: string | Blob | FormData | undefined, headers?: Headers } { - const outputStructure = procedure['~orpc'].contract['~orpc'].route?.outputStructure + const outputStructure = fallbackToGlobalConfig('defaultOutputStructure', procedure['~orpc'].contract['~orpc'].route?.outputStructure) - if (!outputStructure || outputStructure === 'compact') { + if (outputStructure === 'compact') { return this.payloadCodec.encode(output, accept) } - const _expect: 'detailed' = outputStructure - this.assertDetailedOutput(output) const headers = new Headers() diff --git a/packages/openapi/src/adapters/fetch/openapi-procedure-matcher.ts b/packages/openapi/src/adapters/fetch/openapi-procedure-matcher.ts index 40ec9bc3..46ce6a5a 100644 --- a/packages/openapi/src/adapters/fetch/openapi-procedure-matcher.ts +++ b/packages/openapi/src/adapters/fetch/openapi-procedure-matcher.ts @@ -1,6 +1,6 @@ import type { HTTPPath } from '@orpc/contract' import type { Router as BaseHono, ParamIndexMap, Params } from 'hono/router' -import { type ANY_PROCEDURE, type ANY_ROUTER, getLazyRouterPrefix, getRouterChild, isProcedure, unlazy } from '@orpc/server' +import { type ANY_PROCEDURE, type ANY_ROUTER, fallbackToGlobalConfig, getLazyRouterPrefix, getRouterChild, isProcedure, unlazy } from '@orpc/server' import { mapValues } from '@orpc/shared' import { forEachContractProcedure, standardizeHTTPPath } from '../../utils' @@ -75,7 +75,7 @@ export class OpenAPIProcedureMatcher { private add(path: string[], router: ANY_ROUTER): void { const lazies = forEachContractProcedure({ path, router }, ({ path, contract }) => { - const method = contract['~orpc'].route?.method ?? 'POST' + const method = fallbackToGlobalConfig('defaultMethod', contract['~orpc'].route?.method) const httpPath = contract['~orpc'].route?.path ? this.convertOpenAPIPathToRouterPath(contract['~orpc'].route?.path) : `/${path.map(encodeURIComponent).join('/')}` diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 63dff00c..ccb012ee 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -1,9 +1,9 @@ -import type { ContractRouter } from '@orpc/contract' import type { ANY_ROUTER } from '@orpc/server' import type { PublicOpenAPIInputStructureParser } from './openapi-input-structure-parser' import type { PublicOpenAPIOutputStructureParser } from './openapi-output-structure-parser' import type { PublicOpenAPIPathParser } from './openapi-path-parser' import type { SchemaConverter } from './schema-converter' +import { type ContractRouter, fallbackToGlobalConfig } from '@orpc/contract' import { JSONSerializer, type PublicJSONSerializer } from './json-serializer' import { type OpenAPI, OpenApiBuilder } from './openapi' import { OpenAPIContentBuilder, type PublicOpenAPIContentBuilder } from './openapi-content-builder' @@ -99,11 +99,13 @@ export class OpenAPIGenerator { return } - const method = def.route?.method ?? 'POST' + const method = fallbackToGlobalConfig('defaultMethod', def.route?.method) const httpPath = def.route?.path ? standardizeHTTPPath(def.route?.path) : `/${path.map(encodeURIComponent).join('/')}` + const inputStructure = fallbackToGlobalConfig('defaultInputStructure', def.route?.inputStructure) + const outputStructure = fallbackToGlobalConfig('defaultOutputStructure', def.route?.outputStructure) - const { paramsSchema, querySchema, headersSchema, bodySchema } = this.inputStructureParser.parse(contract, def.route?.inputStructure ?? 'compact') - const { headersSchema: resHeadersSchema, bodySchema: resBodySchema } = this.outputStructureParser.parse(contract, def.route?.outputStructure ?? 'compact') + const { paramsSchema, querySchema, headersSchema, bodySchema } = this.inputStructureParser.parse(contract, inputStructure) + const { headersSchema: resHeadersSchema, bodySchema: resBodySchema } = this.outputStructureParser.parse(contract, outputStructure) const params = paramsSchema ? this.parametersBuilder.build('path', paramsSchema, { @@ -161,7 +163,7 @@ export class OpenAPIGenerator { parameters: parameters.length ? parameters : undefined, requestBody, responses: { - [def.route?.successStatus ?? 200]: successResponse, + [fallbackToGlobalConfig('defaultSuccessStatus', def.route?.successStatus)]: successResponse, }, } diff --git a/packages/openapi/src/openapi-input-structure-parser.ts b/packages/openapi/src/openapi-input-structure-parser.ts index 5cbcce82..cbb34cd5 100644 --- a/packages/openapi/src/openapi-input-structure-parser.ts +++ b/packages/openapi/src/openapi-input-structure-parser.ts @@ -1,8 +1,8 @@ -import type { ANY_CONTRACT_PROCEDURE } from '@orpc/contract' import type { PublicOpenAPIPathParser } from './openapi-path-parser' import type { JSONSchema, ObjectSchema } from './schema' import type { SchemaConverter } from './schema-converter' import type { PublicSchemaUtils } from './schema-utils' +import { type ANY_CONTRACT_PROCEDURE, fallbackToGlobalConfig } from '@orpc/contract' import { OpenAPIError } from './openapi-error' export interface OpenAPIInputStructureParseResult { @@ -21,7 +21,7 @@ export class OpenAPIInputStructureParser { parse(contract: ANY_CONTRACT_PROCEDURE, structure: 'compact' | 'detailed'): OpenAPIInputStructureParseResult { const inputSchema = this.schemaConverter.convert(contract['~orpc'].InputSchema, { strategy: 'input' }) - const method = contract['~orpc'].route?.method ?? 'POST' + const method = fallbackToGlobalConfig('defaultMethod', contract['~orpc'].route?.method) const httpPath = contract['~orpc'].route?.path if (this.schemaUtils.isAnySchema(inputSchema)) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4ffbfb39..23f4e532 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,6 +19,8 @@ export * from './router-client' export * from './router-implementer' export * from './types' export * from './utils' + +export { configGlobal, fallbackToGlobalConfig } from '@orpc/contract' export * from '@orpc/shared/error' export const os = new Builder({}) From 71fc0245bdd6d02a534b184578f58573b7976fdd Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 3 Jan 2025 16:18:14 +0700 Subject: [PATCH 3/5] simplify --- packages/contract/src/config.test.ts | 5 +++++ packages/contract/src/config.ts | 17 +++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/contract/src/config.test.ts b/packages/contract/src/config.test.ts index d18db2bf..2d6dcda3 100644 --- a/packages/contract/src/config.test.ts +++ b/packages/contract/src/config.test.ts @@ -17,4 +17,9 @@ it('configGlobal & fallbackToGlobalConfig', () => { expect(fallbackToGlobalConfig('defaultInputStructure', undefined)).toBe('compact') expect(fallbackToGlobalConfig('defaultInputStructure', 'detailed')).toBe('detailed') + + /** Reset to make sure the global config is not affected other tests */ + configGlobal({ + defaultMethod: 'POST', + }) }) diff --git a/packages/contract/src/config.ts b/packages/contract/src/config.ts index b94a74ed..90aae806 100644 --- a/packages/contract/src/config.ts +++ b/packages/contract/src/config.ts @@ -32,18 +32,13 @@ const DEFAULT_CONFIG: Required = { defaultOutputStructure: 'compact', } -const GLOBAL_CONFIG: Required = { - ...DEFAULT_CONFIG, -} +const GLOBAL_CONFIG_REF: { value: ORPCConfig } = { value: DEFAULT_CONFIG } /** * Set the global configuration, this configuration can effect entire project - * If value is undefined, it will use the default value */ export function configGlobal(config: ORPCConfig): void { - for (const [key, value] of Object.entries(config)) { - Reflect.set(GLOBAL_CONFIG, key, value !== undefined ? value : DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]) - } + GLOBAL_CONFIG_REF.value = config } /** @@ -51,7 +46,13 @@ export function configGlobal(config: ORPCConfig): void { */ export function fallbackToGlobalConfig(key: T, value: ORPCConfig[T]): Exclude { if (value === undefined) { - return GLOBAL_CONFIG[key] as any + const fallback = GLOBAL_CONFIG_REF.value[key] + + if (fallback === undefined) { + return DEFAULT_CONFIG[key] as any + } + + return fallback as any } return value as any From 955f595ab806b4226ff837568092bb1f6b017587 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 3 Jan 2025 16:33:14 +0700 Subject: [PATCH 4/5] docs --- apps/content/content/docs/openapi/config.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/content/content/docs/openapi/config.mdx diff --git a/apps/content/content/docs/openapi/config.mdx b/apps/content/content/docs/openapi/config.mdx new file mode 100644 index 00000000..a29fd67d --- /dev/null +++ b/apps/content/content/docs/openapi/config.mdx @@ -0,0 +1,19 @@ +--- +title: Config +description: How to configure your oRPC project. +--- + +```ts twoslash +import { configGlobal } from '@orpc/contract'; // or '@orpc/server' + +configGlobal({ + defaultMethod: 'GET', // Default HTTP method for requests + defaultSuccessStatus: 200, // Default HTTP status for successful responses + defaultInputStructure: 'compact', // Input payload structure: 'compact' or 'expanded' + defaultOutputStructure: 'compact', // Output payload structure: 'compact' or 'expanded' +}); +``` + +I recommend placing this script at the top of the `main.ts` file, where the server is initialized. +Alternatively, include it at the top of the file where you define `global oRPC builders or the app router`. +This ensures the configuration is applied before any other part of your code is executed. \ No newline at end of file From c51115488b6b8d430188fbdaf385383d6580c029 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 3 Jan 2025 16:38:53 +0700 Subject: [PATCH 5/5] throw on defaultSuccessStatus is invalid --- packages/contract/src/config.test.ts | 14 +++++++------- packages/contract/src/config.ts | 7 +++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/contract/src/config.test.ts b/packages/contract/src/config.test.ts index 2d6dcda3..5e7f410e 100644 --- a/packages/contract/src/config.test.ts +++ b/packages/contract/src/config.test.ts @@ -3,23 +3,23 @@ import { configGlobal, fallbackToGlobalConfig } from './config' it('configGlobal & fallbackToGlobalConfig', () => { configGlobal({ defaultMethod: 'GET', + defaultSuccessStatus: 203, }) expect(fallbackToGlobalConfig('defaultMethod', undefined)).toBe('GET') + expect(fallbackToGlobalConfig('defaultSuccessStatus', undefined)).toBe(203) - configGlobal({ - defaultMethod: undefined, - }) + configGlobal({ defaultMethod: undefined, defaultSuccessStatus: undefined }) expect(fallbackToGlobalConfig('defaultMethod', undefined)).toBe('POST') + expect(fallbackToGlobalConfig('defaultSuccessStatus', undefined)).toBe(200) - expect(fallbackToGlobalConfig('defaultMethod', 'DELETE')).toBe('DELETE') + expect(() => configGlobal({ defaultSuccessStatus: 300 })).toThrowError() + expect(fallbackToGlobalConfig('defaultMethod', 'DELETE')).toBe('DELETE') expect(fallbackToGlobalConfig('defaultInputStructure', undefined)).toBe('compact') expect(fallbackToGlobalConfig('defaultInputStructure', 'detailed')).toBe('detailed') /** Reset to make sure the global config is not affected other tests */ - configGlobal({ - defaultMethod: 'POST', - }) + configGlobal({ defaultMethod: undefined, defaultSuccessStatus: undefined }) }) diff --git a/packages/contract/src/config.ts b/packages/contract/src/config.ts index 90aae806..0a94b9c8 100644 --- a/packages/contract/src/config.ts +++ b/packages/contract/src/config.ts @@ -38,6 +38,13 @@ const GLOBAL_CONFIG_REF: { value: ORPCConfig } = { value: DEFAULT_CONFIG } * Set the global configuration, this configuration can effect entire project */ export function configGlobal(config: ORPCConfig): void { + if ( + config.defaultSuccessStatus !== undefined + && (config.defaultSuccessStatus < 200 || config.defaultSuccessStatus > 299) + ) { + throw new Error('[configGlobal] The defaultSuccessStatus must be between 200 and 299') + } + GLOBAL_CONFIG_REF.value = config }