Skip to content

Commit

Permalink
feat: global config (#69)
Browse files Browse the repository at this point in the history
* config and tests

* use global config

* simplify

* docs

* throw on defaultSuccessStatus is invalid
  • Loading branch information
unnoq authored Jan 3, 2025
1 parent 0a2672f commit 6083cd9
Show file tree
Hide file tree
Showing 12 changed files with 155 additions and 21 deletions.
19 changes: 19 additions & 0 deletions apps/content/content/docs/openapi/config.mdx
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/contract/src/config.test-d.ts
Original file line number Diff line number Diff line change
@@ -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')
})
25 changes: 25 additions & 0 deletions packages/contract/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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, defaultSuccessStatus: undefined })

expect(fallbackToGlobalConfig('defaultMethod', undefined)).toBe('POST')
expect(fallbackToGlobalConfig('defaultSuccessStatus', undefined)).toBe(200)

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: undefined, defaultSuccessStatus: undefined })
})
66 changes: 66 additions & 0 deletions packages/contract/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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<ORPCConfig> = {
defaultMethod: 'POST',
defaultSuccessStatus: 200,
defaultInputStructure: 'compact',
defaultOutputStructure: 'compact',
}

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
}

/**
* Fallback the value to the global config if it is undefined
*/
export function fallbackToGlobalConfig<T extends keyof ORPCConfig>(key: T, value: ORPCConfig[T]): Exclude<ORPCConfig[T], undefined> {
if (value === undefined) {
const fallback = GLOBAL_CONFIG_REF.value[key]

if (fallback === undefined) {
return DEFAULT_CONFIG[key] as any
}

return fallback as any
}

return value as any
}
1 change: 1 addition & 0 deletions packages/contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { ContractBuilder } from './builder'

export * from './builder'
export * from './config'
export * from './procedure'
export * from './procedure-decorated'
export * from './router'
Expand Down
6 changes: 4 additions & 2 deletions packages/contract/src/procedure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
HTTPMethod,
HTTPPath,
InputStructure,
OutputStructure,
Schema,
SchemaOutput,
} from './types'
Expand Down Expand Up @@ -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.
Expand All @@ -64,7 +66,7 @@ export interface RouteOptions {
*
* @default 'compact'
*/
outputStructure?: 'compact' | 'detailed'
outputStructure?: OutputStructure
}

export interface ContractProcedureDef<TInputSchema extends Schema, TOutputSchema extends Schema> {
Expand Down
2 changes: 2 additions & 0 deletions packages/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 6 additions & 10 deletions packages/openapi/src/adapters/fetch/openapi-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -86,7 +86,7 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle

return new Response(body, {
headers: resHeaders,
status: contractDef.route?.successStatus ?? 200,
status: fallbackToGlobalConfig('defaultSuccessStatus', contractDef.route?.successStatus),
})
}

Expand Down Expand Up @@ -128,13 +128,13 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle
}

private async decodeInput(procedure: ANY_PROCEDURE, params: Params, request: Request): Promise<unknown> {
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'
Expand All @@ -143,8 +143,6 @@ export class OpenAPIHandler<T extends Context> 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)
Expand All @@ -157,14 +155,12 @@ export class OpenAPIHandler<T extends Context> 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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('/')}`
Expand Down
12 changes: 7 additions & 5 deletions packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
},
}

Expand Down
4 changes: 2 additions & 2 deletions packages/openapi/src/openapi-input-structure-parser.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WELL_CONTEXT, undefined>({})

0 comments on commit 6083cd9

Please sign in to comment.