diff --git a/packages/app/api-contracts/src/apiContracts.ts b/packages/app/api-contracts/src/apiContracts.ts index ceabc0cc1..171de6c17 100644 --- a/packages/app/api-contracts/src/apiContracts.ts +++ b/packages/app/api-contracts/src/apiContracts.ts @@ -1,4 +1,5 @@ import type { ZodSchema, z } from 'zod/v4' +import type { AnyDeleteRoute, AnyGetRoute, AnyPayloadRoute, AnyRoute } from './contractService.js' import type { HttpStatusCode } from './HttpStatusCodes.ts' export type { HttpStatusCode } @@ -264,3 +265,72 @@ export function mapRouteToPath( return routeDefinition.pathResolver(resolverParams) } + +export type InferGetDetails = Route extends GetRouteDefinition< + infer SuccessResponseBodySchema, + infer PathParamsSchema, + infer RequestQuerySchema, + infer RequestHeaderSchema, + infer IsNonJSONResponseExpected, + infer IsEmptyResponseExpected +> + ? { + responseBodySchema: SuccessResponseBodySchema + pathParamsSchema: PathParamsSchema + requestQuerySchema: RequestQuerySchema + requestHeaderSchema: RequestHeaderSchema + isNonJSONResponseExpected: IsNonJSONResponseExpected + isEmptyResponseExpected: IsEmptyResponseExpected + } + : never + +export type InferDeleteDetails = Route extends DeleteRouteDefinition< + infer SuccessResponseBodySchema, + infer PathParamsSchema, + infer RequestQuerySchema, + infer RequestHeaderSchema, + infer IsNonJSONResponseExpected, + infer IsEmptyResponseExpected +> + ? { + responseBodySchema: SuccessResponseBodySchema + pathParamsSchema: PathParamsSchema + requestQuerySchema: RequestQuerySchema + requestHeaderSchema: RequestHeaderSchema + isNonJSONResponseExpected: IsNonJSONResponseExpected + isEmptyResponseExpected: IsEmptyResponseExpected + } + : never + +export type InferPayloadDetails = + Route extends PayloadRouteDefinition< + infer RequestBodySchema, + infer SuccessResponseBodySchema, + infer PathParamsSchema, + infer RequestQuerySchema, + infer RequestHeaderSchema, + infer IsNonJSONResponseExpected, + infer IsEmptyResponseExpected + > + ? { + requestBodySchema: RequestBodySchema + responseBodySchema: SuccessResponseBodySchema + pathParamsSchema: PathParamsSchema + requestQuerySchema: RequestQuerySchema + requestHeaderSchema: RequestHeaderSchema + isNonJSONResponseExpected: IsNonJSONResponseExpected + isEmptyResponseExpected: IsEmptyResponseExpected + } + : never + +export type InferRouteDetails = Route extends AnyGetRoute + ? InferGetDetails + : Route extends AnyDeleteRoute + ? InferDeleteDetails + : Route extends AnyPayloadRoute + ? InferPayloadDetails + : never + +export * from './contractService.js' +export * from './headers/createHeaderBuilderMiddleware.js' +export * from './headers/headerBuilder.js' diff --git a/packages/app/api-contracts/src/contractService.ts b/packages/app/api-contracts/src/contractService.ts new file mode 100644 index 000000000..e7ee041a2 --- /dev/null +++ b/packages/app/api-contracts/src/contractService.ts @@ -0,0 +1,35 @@ +import type { + DeleteRouteDefinition, + GetRouteDefinition, + PayloadRouteDefinition, +} from './apiContracts.js' + +// biome-ignore lint/suspicious/noExplicitAny: This can actually be any type of route +export type AnyGetRoute = GetRouteDefinition +// biome-ignore lint/suspicious/noExplicitAny: This can actually be any type of route +export type AnyDeleteRoute = DeleteRouteDefinition +// biome-ignore lint/suspicious/noExplicitAny: This can actually be any type of route +export type AnyPayloadRoute = PayloadRouteDefinition +export type AnyRoute = AnyGetRoute | AnyDeleteRoute | AnyPayloadRoute + +// biome-ignore lint/suspicious/noExplicitAny: This can actually be any type of route +export type AnyCacheKeyFn = (...args: any[]) => Array + +export type AnyRoutes = { + [key: string]: { + route: AnyRoute + cacheKey: AnyCacheKeyFn + } +} + +export type ContractDefinitions = { + serviceName: string + config: { routes: Routes } +} + +export function definedContract( + service: string, + routes: R, +): ContractDefinitions { + return { serviceName: service, config: { routes } } +} diff --git a/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.test.ts b/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.test.ts new file mode 100644 index 000000000..ad8ab9b03 --- /dev/null +++ b/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.test.ts @@ -0,0 +1,66 @@ +import { createHeaderBuilderMiddleware } from './createHeaderBuilderMiddleware.js' +import { HeaderBuilder } from './headerBuilder.js' +import { describe, expect, it } from 'vitest' + +describe('createHeaderBuilderMiddleware', () => { + it('should create a middleware that adds a header', async () => { + const middleware = createHeaderBuilderMiddleware((builder) => + builder.add('X-Test-Header', 'test-value'), + ) + + const builder = HeaderBuilder.create().with(middleware) + const actual = await builder.resolve() + + expect(actual).toEqual({ 'X-Test-Header': 'test-value' }) + }) + + it('should create a middleware that adds multiple headers', async () => { + const middleware = createHeaderBuilderMiddleware((builder) => + builder.and({ + 'X-Test-Header-1': 'value1', + 'X-Test-Header-2': 'value2', + }), + ) + + const builder = HeaderBuilder.create().with(middleware) + const actual = await builder.resolve() + + expect(actual).toEqual({ + 'X-Test-Header-1': 'value1', + 'X-Test-Header-2': 'value2', + }) + }) + + it('should create a middleware that adds headers asynchronously', async () => { + const middleware = createHeaderBuilderMiddleware(async (builder) => { + const token = await new Promise((resolve) => resolve('async-token')) + return builder.add('authorization', `Bearer ${token}`) + }) + + const builder = HeaderBuilder.create().with(middleware) + const actual = await builder.resolve() + + expect(actual).toEqual({ authorization: 'Bearer async-token' }) + }) + + it('should create a middleware that merges headers from another builder', async () => { + const otherBuilder = HeaderBuilder.create().add('X-Other-Header', 'other-value') + const middleware = createHeaderBuilderMiddleware((builder) => builder.merge(otherBuilder)) + + const builder = HeaderBuilder.create().with(middleware) + const actual = await builder.resolve() + + expect(actual).toEqual({ 'X-Other-Header': 'other-value' }) + }) + + it('should handle middleware that returns a promise of a builder', async () => { + const middleware = createHeaderBuilderMiddleware((builder) => + Promise.resolve(builder.add('X-Promise-Header', 'promise-value')), + ) + + const builder = HeaderBuilder.create().with(middleware) + const actual = await builder.resolve() + + expect(actual).toEqual({ 'X-Promise-Header': 'promise-value' }) + }) +}) diff --git a/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.ts b/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.ts new file mode 100644 index 000000000..6106656b2 --- /dev/null +++ b/packages/app/api-contracts/src/headers/createHeaderBuilderMiddleware.ts @@ -0,0 +1,59 @@ +import { HeaderBuilder, type Headers } from './headerBuilder.js' + +/** + * A helper function that creates a HeaderBuilderMiddleware, it removed the + * complexity of creating a new instance of the middleware class and tracking the input types. + * + * @param middleware - A function that modifies a HeaderBuilder + * @returns - A new instance of HeaderBuilderMiddleware to be used with a HeaderBuilder + */ +export function createHeaderBuilderMiddleware( + middleware: (builder: HeaderBuilder) => HeaderBuilder | Promise>, +) { + return new HeaderBuilderMiddleware(middleware) +} + +// There is no need to have access to the implementation of the HeaderBuilderMiddleware class +// all users should use the createHeaderBuilderMiddleware function to create a new instance, +// but I have to export the type for reference outside the file +export type { HeaderBuilderMiddleware } + +type MiddlewareFn = ( + builder: HeaderBuilder, +) => HeaderBuilder | Promise> + +/** + * A middleware class that allows you to modify a HeaderBuilder in a type-safe way. + * It receives a builder and returns a new builder with the modifications applied. + * + * @example + * ```typescript + * const authMiddleware = createHeaderBuilderMiddleware(async (builder) => { + * const token = await fetchToken() + * return builder.add('authorization', `Bearer ${token}`) + * }) + * + * const builder = HeaderBuilder.create() + * .with(authMiddleware) + * + * const headers = await builder.resolve() // Type of headers is { 'authorization': string } + * console.log(headers) // { 'authorization': 'Bearer ' } + */ +class HeaderBuilderMiddleware { + private readonly middleware: MiddlewareFn + + constructor(middleware: MiddlewareFn) { + this.middleware = middleware + } + + apply(base: HeaderBuilder): HeaderBuilder { + // Using the `from` method to make the promise lazy - it should only resolve when the builder is resolved + return base.from(async () => { + const middlewareBuilder = this.middleware(HeaderBuilder.create()) + + return middlewareBuilder instanceof Promise + ? middlewareBuilder.then((r) => r.resolve()) + : middlewareBuilder.resolve() + }) + } +} diff --git a/packages/app/api-contracts/src/headers/headerBuilder.test.ts b/packages/app/api-contracts/src/headers/headerBuilder.test.ts new file mode 100644 index 000000000..95c4d681d --- /dev/null +++ b/packages/app/api-contracts/src/headers/headerBuilder.test.ts @@ -0,0 +1,134 @@ +import { createHeaderBuilderMiddleware } from './createHeaderBuilderMiddleware.js' +import { HeaderBuilder } from './headerBuilder.js' +import { describe, expect, vitest } from 'vitest' + +// biome-ignore lint/complexity/noBannedTypes: To test empty headers, we need to use an empty object type. +type ExpectedEmptyHeaders = {} + +// It is critical that type safety is maintained when refactoring - this offers a way to unit test the type-system. +// The call will fail to build if the expected type is not correct. +const typeCheck = (_actual: Expected): void => {} + +describe('HeaderBuilder', () => { + it('should create an empty header builder', async () => { + const builder = HeaderBuilder.create() + + const actual = await builder.resolve() + + typeCheck(actual) + + expect(actual).toEqual({}) + }) + + it('should create a header builder with default headers', async () => { + const builder = HeaderBuilder.create({ 'Content-Type': 'application/json' }) + + const actual = await builder.resolve() + + typeCheck<{ 'Content-Type': 'application/json' }>(actual) + + expect(actual).toEqual({ 'Content-Type': 'application/json' }) + }) + + it('should allow individual headers to be added to the builder by name', async () => { + const builder = HeaderBuilder.create().add('Content-Type', 'application/json') + + const actual = await builder.resolve() + + typeCheck<{ 'Content-Type': 'application/json' }>(actual) + + expect(actual).toEqual({ 'Content-Type': 'application/json' }) + }) + + it('should allow multiple headers to be added in one static object', async () => { + const builder = HeaderBuilder.create().and({ + 'Content-Type': 'application/json', + 'X-Api-Key': '1234', + }) + + const actual = await builder.resolve() + + typeCheck<{ 'Content-Type': 'application/json'; 'X-Api-Key': '1234' }>(actual) + + expect(actual).toEqual({ 'Content-Type': 'application/json', 'X-Api-Key': '1234' }) + }) + + it('should allow multiple headers to be added in a promise of one static object', async () => { + const builder = HeaderBuilder.create().and( + Promise.resolve({ + 'Content-Type': 'application/json', + 'X-Api-Key': '1234', + }), + ) + + const actual = await builder.resolve() + + typeCheck<{ 'Content-Type': 'application/json'; 'X-Api-Key': '1234' }>(actual) + + expect(actual).toEqual({ 'Content-Type': 'application/json', 'X-Api-Key': '1234' }) + }) + + it('should allow headers to be added from a factory function', async () => { + const builder = HeaderBuilder.create().from(async () => { + const token = await mockGetToken() + return { authorization: `Bearer ${token}` } + }) + + const actual = await builder.resolve() + + typeCheck<{ authorization: string }>(actual) + + expect(actual).toEqual({ authorization: 'Bearer 1234' }) + }) + + const mockGetToken = async () => '1234' + + it('should allow you to extend the builder with middleware functions', async () => { + const middleware = createHeaderBuilderMiddleware(async (builder) => { + const token = await mockGetToken() + return builder.and({ authorization: `Bearer ${token}` }) + }) + + const builder = HeaderBuilder.create().with(middleware) + + const actual = await builder.resolve() + + typeCheck<{ authorization: string }>(actual) + + expect(actual).toEqual({ authorization: 'Bearer 1234' }) + }) + + it('should lazy load promises and only resolve them when the headers are resolved', async () => { + const factory = vitest.fn(async () => ({ 'Content-Type': 'application/json' }) as const) + const middlewareMock = vitest.fn(async (builder: HeaderBuilder) => + builder.add('X-Api-Key', '1234'), + ) + + const middleware = createHeaderBuilderMiddleware(middlewareMock) + + const builder = HeaderBuilder.create().from(factory).with(middleware) + + expect(factory).not.toHaveBeenCalled() + expect(middlewareMock).not.toHaveBeenCalled() + + const actual = await builder.resolve() + + typeCheck<{ 'Content-Type': 'application/json'; 'X-Api-Key': '1234' }>(actual) + + expect(factory).toHaveBeenCalled() + expect(actual).toEqual({ 'Content-Type': 'application/json', 'X-Api-Key': '1234' }) + }) + + it('should merge two header builders', async () => { + const builder1 = HeaderBuilder.create().add('Content-Type', 'application/json') + const builder2 = HeaderBuilder.create().add('authorization', 'Bearer token') + + const mergedBuilder = builder1.merge(builder2) + + const actual = await mergedBuilder.resolve() + + typeCheck<{ 'Content-Type': 'application/json'; authorization: 'Bearer token' }>(actual) + + expect(actual).toEqual({ 'Content-Type': 'application/json', authorization: 'Bearer token' }) + }) +}) diff --git a/packages/app/api-contracts/src/headers/headerBuilder.ts b/packages/app/api-contracts/src/headers/headerBuilder.ts new file mode 100644 index 000000000..41fadf4a4 --- /dev/null +++ b/packages/app/api-contracts/src/headers/headerBuilder.ts @@ -0,0 +1,211 @@ +import type { HeaderBuilderMiddleware } from './createHeaderBuilderMiddleware.js' + +export type Headers = Record + +// biome-ignore lint/complexity/noBannedTypes: To represent no headers we need use an empty object +export type NoHeaders = {} + +export type HeadersFromBuilder = H extends HeaderBuilder + ? T + : never + +type Factories = (() => Promise | Headers)[] + +/** + * A builder class that helps to build up a set of headers in a type-safe way. + * It allows you to add headers, merge them together, and resolve them into a single object. + * The builder is immutable, so every operation returns a new instance of the builder. + * It offers a middleware function that allows you to modify the builder, asynchronously, in a type-safe way. + * + * @example + * ```typescript + * const authMiddleware = createHeaderBuilderMiddleware(async (builder) => { + * const token = await fetchToken() + * return builder.add('authorization', `Bearer ${token}`) + * }) + * + * const builder = HeaderBuilder.create() + * .add('Content-Type', 'application/json') + * .and({ 'X-Custom-Header': 'custom', 'X-Another-Header': 'another' }) + * .with(authMiddleware) + * + * const headers = await builder.resolve() + * console.log(headers) + * // Prints: { + * // 'Content-Type': 'application/json', + * // 'X-Custom-Header': 'custom', + * // 'X-Another-Header': 'another', + * // 'authorization': 'Bearer ' + * // } + */ +export class HeaderBuilder { + /** + * Creates a new HeaderBuilder, optionally with an initial set of headers. + * + * @example + * ```typescript + * const builder = HeaderBuilder.create() + * + * const builderWithHeaders = HeaderBuilder.create({ 'Content-Type': 'application/json' }) + * + * console.log(builder) // {} + * console.log(builderWithHeaders) // { 'Content-Type': 'application/json' } + * ``` + */ + static create(): HeaderBuilder + static create(initialHeaders: H | (() => H) | (() => Promise)): HeaderBuilder + static create(initialHeaders = {} as H): HeaderBuilder { + return new HeaderBuilder([() => initialHeaders]) + } + + // This is a list of headers that will be put together in the resolve method. + // You can think of this as building up a history of added headers that will be + // merged together when they are needed. + private readonly factories: Factories + + /** + * This constructor is private to prevent the creation of a HeaderBuilder, it's an implementation detail + * that users of this class should not be aware of. The only way to create a HeaderBuilder is through the + * static create method. + * + * @private + */ + private constructor(factories: Factories) { + this.factories = factories + } + + /** + * Adds a single header to the builder by providing a key and a value. + * + * @example + * ```typescript + * const builder = HeaderBuilder.create() + * .add('Content-Type', 'application/json') + * .add('authorization', 'Bearer token') + * + * const headers = await builder.resolve() + * console.log(headers) + * // { 'Content-Type': 'application/json', 'authorization': 'Bearer token' } + * ``` + * + * @param key - The key of the header + * @param value - The value of the header + */ + add(key: K, value: V): HeaderBuilder { + return new HeaderBuilder([...this.factories, () => ({ [key]: value }) as Headers]) + } + + /** + * Adds multiple headers to the builder by providing an object or a promise of an object with the headers. + * + * @example + * ```typescript + * const builder = HeaderBuilder.create() + * .and({ 'Content-Type': 'application/json', 'authorization': 'Bearer token' }) + * .and(Promise.resolve({ 'X-Custom-Header': 'custom', 'X-Another-Header': 'another' })) + * + * const headers = await builder.resolve() + * console.log(headers) + * // Prints: { + * // 'Content-Type': 'application/json', + * // 'authorization': 'Bearer token', + * // 'X-Custom-Header': 'custom', + * // 'X-Another-Header': 'another' + * // } + * ``` + * + * @param extension - An object with the headers to add + */ + and>( + extension: E | Promise, + ): HeaderBuilder { + return new HeaderBuilder([...this.factories, () => extension]) + } + + /** + * Adds a factory function that returns a promise of headers to the builder. + * This is useful when you need to fetch some data asynchronously to build the headers. + * + * @example + * ```typescript + * const builder = HeaderBuilder.create() + * .from(async () => { + * const token = await fetchToken() + * return { 'authorization': `Bearer ${token}` } + * }) + * + * const headers = await builder.resolve() + * console.log(headers) // { 'authorization': 'Bearer ' } + * ``` + * + * @param factory - A function that returns a promise of headers + */ + from(factory: () => E | Promise): HeaderBuilder { + return new HeaderBuilder([...this.factories, factory]) + } + + /** + * Takes a middleware function that receives the current builder and returns a new, modified, builder. + * + * @example + * ```typescript + * const authMiddleware = createHeaderBuilderMiddleware(async (builder) => { + * const token = await fetchToken() + * return builder.add('authorization', `Bearer ${token}`) + * }) + * + * const builder = HeaderBuilder.create() + * .with(authMiddleware) + * + * const headers = await builder.resolve() // Type of headers is { 'authorization': string } + * console.log(headers) // { 'authorization': 'Bearer ' } + * ``` + * + * @param middleware + */ + with(middleware: HeaderBuilderMiddleware) { + return middleware.apply(this) + } + + /** + * Merges the current builder with another builder. + * + * @example + * ```typescript + * const builderA = HeaderBuilder.create() + * .add('Content-Type', 'application/json') + * + * const builderB = HeaderBuilder.create() + * .add('authorization', 'Bearer token') + * + * const mergedBuilder = builderA.merge(builderB) + * + * const headers = await mergedBuilder.resolve() + * console.log(headers) + * // { 'Content-Type': 'application/json', 'authorization': 'Bearer token' } + * ``` + * + * @param builder - The builder to merge with + */ + merge(builder: HeaderBuilder): HeaderBuilder { + return new HeaderBuilder([...this.factories, ...builder.factories]) + } + + /** + * Resolves the headers by waiting for all the promises to resolve and merging them together. + * + * @example + * ```typescript + * const builder = HeaderBuilder.create() + * .add('Content-Type', 'application/json') + * + * const headers = await builder.resolve() + * console.log(headers) // { 'Content-Type': 'application/json' } + */ + async resolve(): Promise { + const headers = this.factories.map((header) => header()) + const resolvedHeaders = await Promise.all(headers) + + return Object.assign({}, ...resolvedHeaders) + } +} diff --git a/packages/app/backend-http-client/package.json b/packages/app/backend-http-client/package.json index eaf77c4f0..50ffab1e2 100644 --- a/packages/app/backend-http-client/package.json +++ b/packages/app/backend-http-client/package.json @@ -43,15 +43,17 @@ }, "peerDependencies": { "@lokalise/node-core": ">=13.1.0", - "@lokalise/api-contracts": ">=5.0.0", + "@lokalise/api-contracts": "5.0.0-exp-testingContracts.6", + "@lokalise/universal-ts-utils": ">=4.4.1", "zod": ">=3.25.56" }, "devDependencies": { "@biomejs/biome": "^2.0.5", - "@lokalise/api-contracts": "^5.0.0", + "@lokalise/api-contracts": "5.0.0-exp-testingContracts.6", "@lokalise/biome-config": "^2.0.0", "@lokalise/node-core": "^14.1.0", "@lokalise/tsconfig": "^1.3.0", + "@lokalise/universal-ts-utils": "^4.4.1", "@types/node": "^24.0.3", "@vitest/coverage-v8": "^3.2.2", "typescript": "^5.8.3", diff --git a/packages/app/backend-http-client/src/contract/contract.ts b/packages/app/backend-http-client/src/contract/contract.ts new file mode 100644 index 000000000..463d15e89 --- /dev/null +++ b/packages/app/backend-http-client/src/contract/contract.ts @@ -0,0 +1,110 @@ +import type { + AnyDeleteRoute, + AnyGetRoute, + AnyPayloadRoute, + AnyRoutes, + ContractDefinitions, + NoHeaders, +} from '@lokalise/api-contracts' +import { assertIsNever } from '@lokalise/universal-ts-utils/node' +import type { Client } from 'undici' +import { sendByDeleteRoute, sendByGetRoute, sendByPayloadRoute } from '../client/httpClient.js' +import type { + AnyRouteOptions, + AnyRouteParameters, + ContractService, + PayloadRouteParameters, +} from './types.js' + +type RouteOptions = { + [K in keyof Routers]: AnyRouteOptions +} + +export function createContractService< + const Routes extends AnyRoutes, + const C extends Client, + const ContractHeaders extends Headers = NoHeaders, +>( + definition: ContractDefinitions, + clientResolver: (service: string) => Promise, + contractHeaders?: ContractHeaders | (() => ContractHeaders) | (() => Promise), + defaultOptions?: Partial>, +): ContractService { + const service = {} as Partial> + + // // Intentionally not awaiting the clientResolver + const clientCache = clientResolver(definition.serviceName) + const contractHeadersCache = + typeof contractHeaders === 'function' ? contractHeaders() : (contractHeaders ?? ({} as Headers)) + + for (const key in definition.config.routes) { + const routeConfig = definition.config.routes[key] + + if (routeConfig === undefined) { + throw new Error(`Route ${key} is not defined in the contract`) + } + + const route = routeConfig.route + + // @ts-ignore + // Is there a way to not need the `@ts-ignore` here? + service[key] = async ( + params: AnyRouteParameters, + options: AnyRouteOptions, + ) => { + const client = await clientCache + + const resolvedHeaders = await Promise.all([ + contractHeadersCache, + 'headers' in params ? (params.headers as Headers) : Promise.resolve({} as Headers), + ]) + + const headers: Headers = resolvedHeaders.reduce( + // biome-ignore lint/performance/noAccumulatingSpread: This is a clean way to merge headers + (acc, headers) => ({ ...acc, ...headers }), + {} as Headers, + ) + + const requestOptions = { + ...defaultOptions?.[key], + ...options, + } + + switch (route.method) { + case 'get': + return sendByGetRoute( + client, + route as AnyGetRoute, + { ...params, headers }, + requestOptions, + ) + + case 'delete': + return sendByDeleteRoute( + client, + route as AnyDeleteRoute, + { ...params, headers }, + requestOptions, + ) + + case 'post': + case 'put': + case 'patch': + return sendByPayloadRoute( + client, + route as AnyPayloadRoute, + { + ...(params as PayloadRouteParameters), + headers, + }, + requestOptions, + ) + + default: + assertIsNever(route) + } + } + } + + return service as ContractService +} diff --git a/packages/app/backend-http-client/src/contract/types.ts b/packages/app/backend-http-client/src/contract/types.ts new file mode 100644 index 000000000..ec51a2c3f --- /dev/null +++ b/packages/app/backend-http-client/src/contract/types.ts @@ -0,0 +1,179 @@ +import type { + AnyDeleteRoute, + AnyGetRoute, + AnyPayloadRoute, + AnyRoute, + AnyRoutes, + ConfiguredContractService, + ContractDefinitions, + Headers, + HeadersFromBuilder, + InferDeleteDetails, + InferGetDetails, + InferPayloadDetails, + InferSchemaInput, + InferSchemaOutput, +} from '@lokalise/api-contracts' +import type { Client } from 'undici' +import type { PayloadRouteRequestParams, RouteRequestParams } from '../client/apiContractTypes.js' +import type { DEFAULT_OPTIONS } from '../client/constants.js' +import type { RequestOptions, RequestResultDefinitiveEither } from '../client/types.js' + +type DEFAULT_THROW_ON_ERROR = typeof DEFAULT_OPTIONS.throwOnError +type ResolveRequiredHeaders = Omit + +export type GetRouteParameters< + Route extends AnyGetRoute, + ExcludeHeaders extends Headers, + Inferred extends InferGetDetails = InferGetDetails, + RequestHeader extends Headers = InferSchemaInput, + RequiredHeaders extends Headers = ResolveRequiredHeaders, +> = RouteRequestParams< + InferSchemaInput, + InferSchemaInput, + keyof RequestHeader extends never ? never : RequiredHeaders +> + +export type GetRouteOptions< + Route extends AnyGetRoute, + DoThrowOnError extends boolean = DEFAULT_THROW_ON_ERROR, + Inferred extends InferGetDetails = InferGetDetails, +> = Omit< + RequestOptions< + Inferred['responseBodySchema'], + Inferred['isEmptyResponseExpected'], + DoThrowOnError + >, + 'body' | 'headers' | 'query' | 'isEmptyResponseExpected' | 'responseSchema' +> + +export type GetRouteReturnType< + Route extends AnyGetRoute, + Inferred extends InferGetDetails = InferGetDetails, +> = Promise< + RequestResultDefinitiveEither< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type DeleteRouteParameters< + Route extends AnyDeleteRoute, + ExcludeHeaders extends Headers, + Inferred extends InferDeleteDetails = InferDeleteDetails, + RequestHeader extends Headers = InferSchemaInput, + RequiredHeaders extends Headers = ResolveRequiredHeaders, +> = RouteRequestParams< + InferSchemaInput, + InferSchemaInput, + keyof RequestHeader extends never ? never : RequiredHeaders +> + +export type DeleteRouteReturnOptions< + Route extends AnyDeleteRoute, + DoThrowOnError extends boolean = DEFAULT_THROW_ON_ERROR, + Inferred extends InferDeleteDetails = InferDeleteDetails, +> = Omit< + RequestOptions< + Inferred['responseBodySchema'], + Inferred['isEmptyResponseExpected'], + DoThrowOnError + >, + 'body' | 'headers' | 'query' | 'isEmptyResponseExpected' | 'responseSchema' +> + +export type DeleteRouteReturnType< + Route extends AnyDeleteRoute, + Inferred extends InferDeleteDetails = InferDeleteDetails, +> = Promise< + RequestResultDefinitiveEither< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type PayloadRouteParameters< + Route extends AnyPayloadRoute, + ExcludeHeaders extends Headers, + Inferred extends InferPayloadDetails = InferPayloadDetails, + RequestHeader extends Headers = InferSchemaInput, + RequiredHeaders extends Headers = ResolveRequiredHeaders, +> = PayloadRouteRequestParams< + InferSchemaInput, + InferSchemaInput, + InferSchemaInput, + keyof RequestHeader extends never ? never : RequiredHeaders +> + +export type PayloadRouteReturnOptions< + Route extends AnyPayloadRoute, + DoThrowOnError extends boolean = DEFAULT_THROW_ON_ERROR, + Inferred extends InferPayloadDetails = InferPayloadDetails, +> = Omit< + RequestOptions< + Inferred['responseBodySchema'], + Inferred['isEmptyResponseExpected'], + DoThrowOnError + >, + 'body' | 'headers' | 'query' | 'isEmptyResponseExpected' | 'responseSchema' +> + +export type PayloadRouteReturnType< + Route extends AnyPayloadRoute, + Inferred extends InferPayloadDetails = InferPayloadDetails, +> = Promise< + RequestResultDefinitiveEither< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type ContractService< + Routes extends AnyRoutes, + ContractHeaders extends Headers, + DoThrowOnError extends boolean = DEFAULT_THROW_ON_ERROR, +> = { + [K in keyof Routes]: Routes[K] extends { route: infer T } + ? T extends AnyGetRoute + ? ( + params: GetRouteParameters, + options: GetRouteOptions, + ) => GetRouteReturnType + : T extends AnyDeleteRoute + ? ( + params: DeleteRouteParameters, + options: DeleteRouteReturnOptions, + ) => DeleteRouteReturnType + : T extends AnyPayloadRoute + ? ( + params: PayloadRouteParameters, + options: PayloadRouteReturnOptions, + ) => PayloadRouteReturnType + : never + : never +} + +export type AnyRouteParameters< + T extends AnyRoute, + ExcludeHeaders extends Headers, +> = T extends AnyGetRoute + ? GetRouteParameters + : T extends AnyDeleteRoute + ? DeleteRouteParameters + : T extends AnyPayloadRoute + ? PayloadRouteParameters + : never + +export type AnyRouteOptions< + T extends AnyRoute, + DoThrowOnError extends boolean = DEFAULT_THROW_ON_ERROR, +> = T extends AnyGetRoute + ? GetRouteOptions + : T extends AnyDeleteRoute + ? DeleteRouteReturnOptions + : T extends AnyPayloadRoute + ? PayloadRouteReturnOptions + : never diff --git a/packages/app/frontend-http-client/package.json b/packages/app/frontend-http-client/package.json index 756dfdeb1..26448f5b6 100644 --- a/packages/app/frontend-http-client/package.json +++ b/packages/app/frontend-http-client/package.json @@ -41,12 +41,14 @@ }, "peerDependencies": { "@lokalise/api-contracts": ">=5.0.0", + "@lokalise/universal-ts-utils": ">=4.4.1", "wretch": "^2.8.0", "zod": ">=3.25.56" }, "devDependencies": { "@biomejs/biome": "^2.0.5", "@lokalise/api-contracts": "^5.0.0", + "@lokalise/universal-ts-utils": "^4.4.1", "@lokalise/biome-config": "^2.0.0", "@lokalise/tsconfig": "~1.3.0", "@types/node": "^24.0.3", diff --git a/packages/app/frontend-http-client/src/client.ts b/packages/app/frontend-http-client/src/client.ts index d887601a7..64105f9ea 100644 --- a/packages/app/frontend-http-client/src/client.ts +++ b/packages/app/frontend-http-client/src/client.ts @@ -402,6 +402,7 @@ export function sendByPayloadRoute< >( wretch: T, routeDefinition: PayloadRouteDefinition< + // @ts-expect-error RequestBodySchema, ResponseBodySchema, PathParamsSchema, @@ -411,13 +412,18 @@ export function sendByPayloadRoute< IsEmptyResponseExpected >, params: PayloadRouteRequestParams< + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput >, ): Promise< RequestResultType< + // @ts-expect-error InferSchemaOutput, IsNonJSONResponseExpected, IsEmptyResponseExpected @@ -444,6 +450,7 @@ export function sendByPayloadRoute< }) } +// @ts-ignore export function sendByGetRoute< T extends WretchInstance, ResponseBodySchema extends z.Schema | undefined = undefined, @@ -455,6 +462,7 @@ export function sendByGetRoute< >( wretch: T, routeDefinition: GetRouteDefinition< + // @ts-expect-error ResponseBodySchema, PathParamsSchema, RequestQuerySchema, @@ -463,18 +471,22 @@ export function sendByGetRoute< IsEmptyResponseExpected >, params: RouteRequestParams< + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput >, ): Promise< RequestResultType< + // @ts-expect-error InferSchemaOutput, IsNonJSONResponseExpected, IsEmptyResponseExpected > > { - // @ts-expect-error fixme + // @ts-expect-error return sendGet(wretch, { isEmptyResponseExpected: routeDefinition.isEmptyResponseExpected, isNonJSONResponseExpected: routeDefinition.isNonJSONResponseExpected, @@ -491,6 +503,8 @@ export function sendByGetRoute< }) } +// @ts-ignore +// @ts-ignore export function sendByDeleteRoute< T extends WretchInstance, ResponseBodySchema extends z.Schema | undefined = undefined, @@ -502,6 +516,7 @@ export function sendByDeleteRoute< >( wretch: T, routeDefinition: DeleteRouteDefinition< + // @ts-expect-error ResponseBodySchema, PathParamsSchema, RequestQuerySchema, @@ -510,18 +525,22 @@ export function sendByDeleteRoute< IsEmptyResponseExpected >, params: RouteRequestParams< + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput, + // @ts-expect-error InferSchemaInput >, ): Promise< RequestResultType< + // @ts-expect-error InferSchemaOutput, IsNonJSONResponseExpected, IsEmptyResponseExpected > > { - // @ts-expect-error fixme + // @ts-expect-error return sendDelete(wretch, { isEmptyResponseExpected: routeDefinition.isEmptyResponseExpected, isNonJSONResponseExpected: routeDefinition.isNonJSONResponseExpected, diff --git a/packages/app/frontend-http-client/src/contract.ts b/packages/app/frontend-http-client/src/contract.ts new file mode 100644 index 000000000..f991adb72 --- /dev/null +++ b/packages/app/frontend-http-client/src/contract.ts @@ -0,0 +1,82 @@ +import type { + AnyDeleteRoute, + AnyGetRoute, + AnyPayloadRoute, + AnyRoutes, + ContractDefinitions, + Headers, + NoHeaders, +} from '@lokalise/api-contracts' +import { assertIsNever } from '@lokalise/universal-ts-utils/node' +import type { Wretch } from 'wretch' +import { sendByDeleteRoute, sendByGetRoute, sendByPayloadRoute } from './client.js' +import type { + AnyRouteParameters, + ContractService, + PayloadRouteParameters, + WretchInstance, +} from './types.js' + +export function createContractService< + const Routes extends AnyRoutes, + const Client extends WretchInstance, + const ContractHeaders extends Headers = NoHeaders, +>( + definition: ContractDefinitions, + clientResolver: (service: string) => Promise, + contractHeaders?: ContractHeaders | (() => ContractHeaders) | (() => Promise), +): ContractService { + const service = {} as Partial> + + // Intentionally not awaiting the clientResolver + const clientCache = clientResolver(definition.serviceName) + const contractHeadersCache = + typeof contractHeaders === 'function' ? contractHeaders() : (contractHeaders ?? ({} as Headers)) + + for (const key in definition.config.routes) { + const routeConfig = definition.config.routes[key] + + if (routeConfig === undefined) { + throw new Error(`Route ${key} is not defined in the contract`) + } + + const route = routeConfig.route + + // biome-ignore lint/suspicious/noExplicitAny: This is to get around TypeScript's limitation of assignment of generic records + ;(service as any)[key] = async (params: AnyRouteParameters) => { + const client = await clientCache + + const resolvedHeaders = await Promise.all([ + contractHeadersCache, + 'headers' in params ? (params.headers as Headers) : Promise.resolve({} as Headers), + ]) + + const headers: Headers = resolvedHeaders.reduce( + // biome-ignore lint/performance/noAccumulatingSpread: This is a clean way to merge headers + (acc, headers) => ({ ...acc, ...headers }), + {} as Headers, + ) + + switch (route.method) { + case 'get': + return sendByGetRoute(client, route as AnyGetRoute, { ...params, headers }) + + case 'delete': + return sendByDeleteRoute(client, route as AnyDeleteRoute, { ...params, headers }) + + case 'post': + case 'put': + case 'patch': + return sendByPayloadRoute(client, route as AnyPayloadRoute, { + ...(params as PayloadRouteParameters), + headers, + }) + + default: + assertIsNever(route) + } + } + } + + return service as ContractService +} diff --git a/packages/app/frontend-http-client/src/index.ts b/packages/app/frontend-http-client/src/index.ts index 058452f49..9975eb7e6 100644 --- a/packages/app/frontend-http-client/src/index.ts +++ b/packages/app/frontend-http-client/src/index.ts @@ -10,3 +10,5 @@ export { sendPut, UNKNOWN_SCHEMA, } from './client.ts' + +export { createContractService } from './contract.ts' diff --git a/packages/app/frontend-http-client/src/types.ts b/packages/app/frontend-http-client/src/types.ts index 83ec617c4..633ce02ab 100644 --- a/packages/app/frontend-http-client/src/types.ts +++ b/packages/app/frontend-http-client/src/types.ts @@ -1,3 +1,16 @@ +import type { + AnyDeleteRoute, + AnyGetRoute, + AnyPayloadRoute, + AnyRoute, + AnyRoutes, + Headers, + InferDeleteDetails, + InferGetDetails, + InferPayloadDetails, + InferSchemaInput, + InferSchemaOutput, +} from '@lokalise/api-contracts' import type { Wretch, WretchResponse } from 'wretch' import type { ZodSchema, z } from 'zod/v4' @@ -197,3 +210,104 @@ export type RouteRequestParams< // biome-ignore lint/suspicious/noExplicitAny: We don't know which addons Wretch will have, and we don't really care, hence any export type WretchInstance = Wretch + +export type GetRouteParameters< + Route extends AnyGetRoute, + ExcludeHeaders extends Headers, + Inferred extends InferGetDetails = InferGetDetails, +> = RouteRequestParams< + InferSchemaInput, + InferSchemaInput, + keyof InferSchemaInput extends never + ? never + : keyof ExcludeHeaders extends never + ? InferSchemaInput + : Omit, keyof ExcludeHeaders> +> + +export type GetRouteReturnType< + Route extends AnyGetRoute, + Inferred extends InferGetDetails = InferGetDetails, +> = Promise< + RequestResultType< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type DeleteRouteParameters< + Route extends AnyDeleteRoute, + ExcludeHeaders extends Headers, + Inferred extends InferDeleteDetails = InferDeleteDetails, +> = RouteRequestParams< + InferSchemaInput, + InferSchemaInput, + keyof InferSchemaInput extends never + ? never + : keyof ExcludeHeaders extends never + ? InferSchemaInput + : Omit, keyof ExcludeHeaders> +> + +export type DeleteRouteReturnType< + Route extends AnyDeleteRoute, + Inferred extends InferDeleteDetails = InferDeleteDetails, +> = Promise< + RequestResultType< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type PayloadRouteParameters< + Route extends AnyPayloadRoute, + ExcludeHeaders extends Headers, + Inferred extends InferPayloadDetails = InferPayloadDetails, +> = PayloadRouteRequestParams< + InferSchemaInput, + InferSchemaInput, + InferSchemaInput, + keyof InferSchemaInput extends never + ? never + : keyof ExcludeHeaders extends never + ? InferSchemaInput + : Omit, keyof ExcludeHeaders> +> + +export type PayloadRouteReturnType< + Route extends AnyPayloadRoute, + Inferred extends InferPayloadDetails = InferPayloadDetails, +> = Promise< + RequestResultType< + InferSchemaOutput, + Inferred['isNonJSONResponseExpected'], + Inferred['isEmptyResponseExpected'] + > +> + +export type ContractService = { + [K in keyof Routes]: Routes[K] extends { + route: infer T + } + ? T extends AnyGetRoute + ? (params: GetRouteParameters) => GetRouteReturnType + : T extends AnyDeleteRoute + ? (params: DeleteRouteParameters) => DeleteRouteReturnType + : T extends AnyPayloadRoute + ? (params: PayloadRouteParameters) => PayloadRouteReturnType + : never + : never +} + +export type AnyRouteParameters< + T extends AnyRoute, + ExcludeHeaders extends Headers, +> = T extends AnyGetRoute + ? GetRouteParameters + : T extends AnyDeleteRoute + ? DeleteRouteParameters + : T extends AnyPayloadRoute + ? PayloadRouteParameters + : never diff --git a/packages/app/universal-ts-utils/src/node.ts b/packages/app/universal-ts-utils/src/node.ts index 94e6d2b41..dd5f339dd 100644 --- a/packages/app/universal-ts-utils/src/node.ts +++ b/packages/app/universal-ts-utils/src/node.ts @@ -29,6 +29,7 @@ export * from './public/object/transformToKebabCase.ts' // string export * from './public/string/trimText.ts' // type +export * from './public/type/assertIsNever.js' export * from './public/type/FreeformRecord.ts' export * from './public/type/hasMessage.ts' export * from './public/type/isError.ts' diff --git a/packages/app/universal-ts-utils/src/public/type/assertIsNever.test.ts b/packages/app/universal-ts-utils/src/public/type/assertIsNever.test.ts new file mode 100644 index 000000000..0bf4dbf63 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/type/assertIsNever.test.ts @@ -0,0 +1,8 @@ +import { expect } from 'vitest' +import { assertIsNever } from './assertIsNever.js' + +describe('assertIsNever', () => { + it('should throw an error if a non-never value is passed in', () => { + expect(() => assertIsNever('not-never' as never)).toThrowError() + }) +}) diff --git a/packages/app/universal-ts-utils/src/public/type/assertIsNever.ts b/packages/app/universal-ts-utils/src/public/type/assertIsNever.ts new file mode 100644 index 000000000..1c360c841 --- /dev/null +++ b/packages/app/universal-ts-utils/src/public/type/assertIsNever.ts @@ -0,0 +1,3 @@ +export const assertIsNever = (value: never): never => { + throw new Error(`Unexpected value: ${value}`) +}