diff --git a/.eslintrc.js b/.eslintrc.js index 7755435..a2e5626 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -187,11 +187,7 @@ module.exports = { }, { files: ['**/src/**'], - excludedFiles: [ - '**/__tests__/**', - '**/*.test.ts?(x)', - '**/__benches__/**', - ], + excludedFiles: ['**/__tests__/**', '**/*.test.ts?(x)'], rules: { 'import/no-extraneous-dependencies': [ 'error', diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml deleted file mode 100644 index d859ba0..0000000 --- a/.github/workflows/codspeed.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: 🐇 CodSpeed benchmarks - -on: - push: - branches: - - 'main' - pull_request: - # `workflow_dispatch` allows CodSpeed to trigger backtest - # performance analysis in order to generate initial data. - workflow_dispatch: - -# cancel previous runs on the same PR -concurrency: - group: ${{ github.ref }}/codspeed - cancel-in-progress: true - -jobs: - benchmarks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} - # We need to fetch all branches and commits so that Nx affected has a base to compare against. - fetch-depth: 0 - - uses: nrwl/nx-set-shas@v3 - - name: Install & cache node dependencies - uses: ./.github/actions/install-node-deps - - name: Package the repo - run: pnpm package - - name: Run benchmarks - uses: CodSpeedHQ/action@v1 - with: - run: pnpm -r bench diff --git a/nx.json b/nx.json index c951886..1b19502 100644 --- a/nx.json +++ b/nx.json @@ -23,10 +23,6 @@ "production": ["!{projectRoot}/**/*.test.tsx?"] }, "targetDefaults": { - "bench": { - "inputs": ["default", "^production"], - "dependsOn": ["^package"] - }, "build": { "inputs": ["production", "^production"], "dependsOn": ["^build", "^package"] diff --git a/package.json b/package.json index baed535..8897f77 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "Typescript" ], "scripts": { - "bench": "nx run-many --target=bench --all --parallel=4", "build": "nx run-many --target=build --all --parallel=4", "deploy": "nx run-many --target=deploy --all --parallel=4", "deploy-affected": "nx affected --target=deploy", diff --git a/packages/aws-zod-interface-contracts/package.json b/packages/aws-zod-interface-contracts/package.json index 15e762c..dcacbc5 100644 --- a/packages/aws-zod-interface-contracts/package.json +++ b/packages/aws-zod-interface-contracts/package.json @@ -22,7 +22,6 @@ "module": "index.js", "types": "index.d.ts", "scripts": { - "bench": "pnpm tsx src/__benches__/index.ts", "clean": "rimraf dist *.tsbuildinfo", "format-check": "prettier --check . --ignore-path ../../.prettierignore", "format-fix": "prettier --write . --ignore-path ../../.prettierignore", @@ -41,22 +40,24 @@ "watch": "pnpm clean && concurrently 'pnpm:package-* --watch'" }, "dependencies": { + "@anatine/zod-openapi": "^2.2.1", "@types/http-errors": "^2.0.1", "http-errors": "^2.0.0", + "lodash": "^4.17.21", + "openapi-types": "^12.1.3", "ts-toolbelt": "^9.6.0" }, "devDependencies": { - "@codspeed/tinybench-plugin": "^2.2.0", "@types/aws-lambda": "^8.10.125", "@types/lodash": "^4.14.191", "@types/node": "^18.16.1", "@vitest/coverage-c8": "0.30.0", + "axios": "^1.2.2", "concurrently": "^8.0.0", "dependency-cruiser": "^13.0.0", "eslint": "^8.29.0", "prettier": "^2.8.1", "rimraf": "^5.0.0", - "tinybench": "^2.3.1", "ts-node": "^10.9.1", "tsc-alias": "^1.8.2", "tsup": "^6.7.0", @@ -67,11 +68,15 @@ "zod": "^3.22.4" }, "peerDependencies": { + "axios": ">=1", "zod": ">=3" }, "peerDependenciesMeta": { "zod": { "optional": false + }, + "axios": { + "optional": true } } } diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.test.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.test.ts new file mode 100644 index 0000000..12d6d1d --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.test.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; +import { z } from 'zod'; + +import { HttpStatusCodes } from 'types/http'; + +import { ApiGatewayContract } from '../../ApiGatewayContract'; +import { getAxiosRequest } from './axiosRequest'; + +describe('apiGateway axios request', () => { + const pathParametersSchema = z.object({ + userId: z.string(), + pageNumber: z.string(), + }); + + const queryStringParametersSchema = z.object({ + testId: z.string(), + optionalParam: z.string().optional(), + }); + + const headersSchema = z.object({ + myHeader: z.string(), + }); + + const bodySchema = z.object({ + foo: z.string(), + bar: z.array(z.string()).optional(), + }); + + const outputSchema = z.object({ + id: z.string(), + name: z.string(), + }); + + describe('httpApi, when all parameters are set', () => { + const httpApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/users/{userId}', + method: 'GET', + integrationType: 'httpApi', + pathParametersSchema, + queryStringParametersSchema, + headersSchema, + bodySchema, + outputSchemas: { + [HttpStatusCodes.OK]: outputSchema, + }, + }); + + it('should have the correct axiosRequest', async () => { + await expect(() => + getAxiosRequest( + httpApiContract, + axios.create({ baseURL: 'http://blob.test' }), + { + pathParameters: { + userId: 'azer', + pageNumber: 'zert', + }, + queryStringParameters: { + testId: 'erty', + }, + headers: { + myHeader: 'rtyu', + }, + body: { + foo: 'tyui', + bar: ['yuio'], + }, + }, + ), + ).rejects.toMatchObject({ + config: { + url: '/users/azer', + data: '{"foo":"tyui","bar":["yuio"]}', + params: { testId: 'erty' }, + }, + }); + }); + }); + + describe('restApi, when it is instantiated with a subset of schemas', () => { + const restApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/coucou', + method: 'POST', + integrationType: 'httpApi', + }); + + it('should have the correct axios request ', async () => { + await expect(() => + getAxiosRequest( + restApiContract, + axios.create({ baseURL: 'http://blob.test' }), + {}, + ), + ).rejects.toMatchObject({ + config: { + url: '/coucou', + }, + }); + }); + }); +}); diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.ts new file mode 100644 index 0000000..b81c044 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/axiosRequest.ts @@ -0,0 +1,24 @@ +import { AxiosInstance, AxiosResponse } from 'axios'; + +import { GenericApiGatewayContract } from '../../ApiGatewayContract'; +import { OutputType, RequestArguments } from '../../types'; +import { getRequestParameters } from '../requestParameters'; + +export const getAxiosRequest = async < + Contract extends GenericApiGatewayContract, +>( + contract: Contract, + axiosClient: AxiosInstance, + requestArguments: RequestArguments, +): Promise['body']>> => { + const { path, method, queryStringParameters, body, headers } = + getRequestParameters(contract, requestArguments); + + return axiosClient.request({ + method, + url: path, + headers, + data: body, + params: queryStringParameters, + }); +}; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/index.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/index.ts new file mode 100644 index 0000000..91ba624 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/axiosRequest/index.ts @@ -0,0 +1 @@ +export { getAxiosRequest } from './axiosRequest'; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.test.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.test.ts new file mode 100644 index 0000000..a237e43 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.test.ts @@ -0,0 +1,181 @@ +/* eslint-disable max-lines */ +/// + +import { z } from 'zod'; + +import { HttpStatusCodes } from 'types/http'; + +import { ApiGatewayContract } from '../../ApiGatewayContract'; +import { getFetchRequest } from './fetchRequest'; + +const mockedFetch = vi.fn(() => + Promise.resolve({ + json: () => { + return Promise.resolve(undefined); + }, + }), +); + +describe('apiGateway fetch request', () => { + const pathParametersSchema = z.object({ + userId: z.string(), + pageNumber: z.string(), + }); + + const queryStringParametersSchema = z.object({ + testId: z.string(), + optionalParam: z.string().optional(), + }); + + const headersSchema = z.object({ + myHeader: z.string(), + }); + + const bodySchema = z.object({ + foo: z.string(), + bar: z.array(z.string()).optional(), + }); + + const outputSchema = z.object({ + id: z.string(), + name: z.string(), + }); + + describe('restApi, when all parameters are set', () => { + const httpApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/users/{userId}', + method: 'POST', + integrationType: 'httpApi', + pathParametersSchema, + queryStringParametersSchema, + headersSchema, + bodySchema, + outputSchemas: { + [HttpStatusCodes.OK]: outputSchema, + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should have the correct axiosRequest when all values are defined', async () => { + await getFetchRequest( + httpApiContract, + mockedFetch as unknown as typeof fetch, + { + pathParameters: { + userId: 'azer', + pageNumber: 'zert', + }, + queryStringParameters: { + testId: 'er', + optionalParam: 'ty', + }, + headers: { + myHeader: 'rtyu', + }, + body: { + foo: 'tyui', + bar: ['yuio'], + }, + baseUrl: 'http://localhost:3000', + }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + new URL('http://localhost:3000/users/azer?testId=er&optionalParam=ty'), + { + body: '{"foo":"tyui","bar":["yuio"]}', + headers: { myHeader: 'rtyu', 'Content-Type': 'application/json' }, + method: 'POST', + }, + ); + }); + + it('should have the correct axiosRequest when some queryStringParameters are undefined', async () => { + await getFetchRequest( + httpApiContract, + mockedFetch as unknown as typeof fetch, + { + pathParameters: { + userId: 'azer', + pageNumber: 'zert', + }, + queryStringParameters: { + testId: 'erty', + optionalParam: undefined, + }, + headers: { + myHeader: 'rtyu', + }, + body: { + foo: 'tyui', + bar: ['yuio'], + }, + baseUrl: 'http://localhost:3000', + }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + new URL('http://localhost:3000/users/azer?testId=erty'), + { + body: '{"foo":"tyui","bar":["yuio"]}', + headers: { myHeader: 'rtyu', 'Content-Type': 'application/json' }, + method: 'POST', + }, + ); + }); + }); + + describe('httpApi, when it is instanciated with a subset of schemas', () => { + const restApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/coucou', + method: 'GET', + integrationType: 'httpApi', + }); + + it('should have the correct axios request ', async () => { + await getFetchRequest( + restApiContract, + mockedFetch as unknown as typeof fetch, + { baseUrl: 'http://localhost:3000' }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + new URL('http://localhost:3000/coucou'), + { + body: undefined, + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + }, + ); + }); + }); + + describe('httpApi without base url', () => { + const httpApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/coucou', + method: 'GET', + integrationType: 'httpApi', + queryStringParametersSchema, + }); + + it('should have the correct axios request ', async () => { + await getFetchRequest( + httpApiContract, + mockedFetch as unknown as typeof fetch, + { + queryStringParameters: { + testId: 'erty', + }, + }, + ); + expect(mockedFetch).toHaveBeenCalledWith('/coucou?testId=erty', { + body: undefined, + headers: { 'Content-Type': 'application/json' }, + method: 'GET', + }); + }); + }); +}); diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.ts new file mode 100644 index 0000000..2a06f25 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/fetchRequest.ts @@ -0,0 +1,39 @@ +/// + +import { GenericApiGatewayContract } from '../../ApiGatewayContract'; +import { OutputType, RequestArguments } from '../../types'; +import { getRequestParameters } from '../requestParameters'; +import { combineUrls } from '../utils'; + +export const getFetchRequest = async < + Contract extends GenericApiGatewayContract, +>( + contract: Contract, + fetchFunction: typeof fetch, + options: RequestArguments & { baseUrl?: URL | string }, +): Promise> => { + const { path, method, queryStringParameters, body, headers } = + getRequestParameters(contract, options); + + let url; + const searchString = new URLSearchParams(queryStringParameters).toString(); + + if (options.baseUrl !== undefined) { + url = combineUrls(path, options.baseUrl); + + url.search = searchString; + } else { + url = `${path}?${searchString}`; + } + + const response = await fetchFunction(url, { + method, + headers, + body: JSON.stringify(body), + }); + + return { + statusCode: response.status, + body: (await response.json()) as OutputType['body'], + } as OutputType; +}; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/index.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/index.ts new file mode 100644 index 0000000..7c660be --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/fetchRequest/index.ts @@ -0,0 +1 @@ +export { getFetchRequest } from './fetchRequest'; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/index.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/index.ts index e8caed0..f3cf7d5 100644 --- a/packages/aws-zod-interface-contracts/src/apiGateway/features/index.ts +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/index.ts @@ -1 +1,4 @@ export * from './lambdaHandler'; +export * from './fetchRequest'; +export * from './axiosRequest'; +export * from './openApiDocumentation'; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/index.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/index.ts new file mode 100644 index 0000000..7b3335d --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/index.ts @@ -0,0 +1 @@ +export { getContractDocumentation } from './openApiDocumentation'; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.test.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.test.ts new file mode 100644 index 0000000..ef2d1dc --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.test.ts @@ -0,0 +1,167 @@ +import { z } from 'zod'; + +import { ApiGatewayContract } from 'apiGateway/ApiGatewayContract'; +import { HttpStatusCodes } from 'types/http'; + +import { getContractDocumentation } from './openApiDocumentation'; + +describe('apiGateway openApi contract documentation', () => { + const pathParametersSchema = z.object({ + userId: z.string(), + pageNumber: z.string(), + }); + + const queryStringParametersSchema = z.object({ + testId: z.string(), + }); + + const headersSchema = z.object({ + myHeader: z.string(), + }); + + const bodySchema = z.object({ + foo: z.string(), + }); + + const outputSchema = z.object({ + id: z.string(), + name: z.string(), + }); + + const unauthorizedSchema = z.object({ + message: z.string(), + }); + + const outputSchemas = { + [HttpStatusCodes.OK]: outputSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + }; + + describe('httpApi, when all parameters are set', () => { + const httpApiContract = new ApiGatewayContract({ + id: 'testContract', + path: '/users/{userId}', + method: 'GET', + integrationType: 'httpApi', + pathParametersSchema, + queryStringParametersSchema, + headersSchema, + bodySchema, + outputSchemas, + }); + + it('should generate open api documentation', () => { + expect(getContractDocumentation(httpApiContract)).toEqual({ + path: '/users/{userId}', + method: 'get', + documentation: { + parameters: [ + { + in: 'header', + name: 'myHeader', + required: true, + schema: { + type: 'string', + }, + }, + { + in: 'query', + name: 'testId', + required: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'userId', + required: true, + schema: { + type: 'string', + }, + }, + { + in: 'path', + name: 'pageNumber', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + required: ['foo'], + }, + }, + }, + }, + responses: { + '200': { + description: 'Response: 200', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + required: ['id', 'name'], + }, + }, + }, + }, + '401': { + description: 'Response: 401', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + }, + }, + required: ['message'], + }, + }, + }, + }, + }, + }, + }); + }); + }); + + describe('restApi, when it is instanciated with a subset of schemas', () => { + const restApiContract = new ApiGatewayContract({ + id: 'testContract', + path: 'coucou', + method: 'POST', + integrationType: 'restApi', + }); + + it('should generate open api documentation', () => { + expect(getContractDocumentation(restApiContract)).toEqual({ + path: 'coucou', + method: 'post', + documentation: { + responses: {}, // no response is configured + }, + }); + }); + }); +}); diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.ts new file mode 100644 index 0000000..f474cf2 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/openApiDocumentation/openApiDocumentation.ts @@ -0,0 +1,107 @@ +import { generateSchema } from '@anatine/zod-openapi'; +import isUndefined from 'lodash/isUndefined'; +import omitBy from 'lodash/omitBy'; +import { OpenAPIV3 } from 'openapi-types'; + +import { GenericApiGatewayContract } from 'apiGateway/ApiGatewayContract'; +import { ContractOpenApiDocumentation } from 'types/contractOpenApiDocumentation'; + +export const getContractDocumentation = < + Contract extends GenericApiGatewayContract, +>( + contract: Contract, +): ContractOpenApiDocumentation => { + const initialDocumentation: OpenAPIV3.OperationObject = { + responses: {}, + }; + + const definedOutputSchema = omitBy(contract.outputSchemas, isUndefined); + console.log(definedOutputSchema); + + // add responses to the object + const contractDocumentation = Object.keys(definedOutputSchema).reduce( + (config: OpenAPIV3.OperationObject, responseCode) => { + const schema = definedOutputSchema[responseCode]; + + if (schema === undefined) { + return config; + } + + const openApiSchema = generateSchema(schema); + + return { + ...config, + responses: { + ...config.responses, + [responseCode]: { + description: `Response: ${responseCode}`, + content: { + 'application/json': { + schema: openApiSchema as OpenAPIV3.SchemaObject, + }, + }, + }, + }, + }; + }, + initialDocumentation, + ); + + if (contract.pathParametersSchema !== undefined) { + contractDocumentation.parameters = [ + ...Object.entries(contract.pathParametersSchema.shape).map( + ([variableName, variableDefinition]) => ({ + name: variableName, + in: 'path', + schema: generateSchema(variableDefinition) as OpenAPIV3.SchemaObject, + required: !variableDefinition.isOptional(), + }), + ), + ...(contractDocumentation.parameters ?? []), + ]; + } + + if (contract.queryStringParametersSchema !== undefined) { + contractDocumentation.parameters = [ + ...Object.entries(contract.queryStringParametersSchema.shape).map( + ([variableName, variableDefinition]) => ({ + name: variableName, + in: 'query', + schema: generateSchema(variableDefinition) as OpenAPIV3.SchemaObject, + required: !variableDefinition.isOptional(), + }), + ), + ...(contractDocumentation.parameters ?? []), + ]; + } + + if (contract.headersSchema !== undefined) { + contractDocumentation.parameters = [ + ...Object.entries(contract.headersSchema.shape).map( + ([variableName, variableDefinition]) => ({ + name: variableName, + in: 'header', + schema: generateSchema(variableDefinition) as OpenAPIV3.SchemaObject, + required: !variableDefinition.isOptional(), + }), + ), + ...(contractDocumentation.parameters ?? []), + ]; + } + + if (contract.bodySchema !== undefined) { + contractDocumentation.requestBody = { + content: { + 'application/json': { + schema: generateSchema(contract.bodySchema) as OpenAPIV3.SchemaObject, + }, + }, + }; + } + + return { + path: contract.path, + method: contract.method.toLowerCase(), + documentation: contractDocumentation, + }; +}; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/requestParameters.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/requestParameters.ts new file mode 100644 index 0000000..68f228b --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/requestParameters.ts @@ -0,0 +1,42 @@ +import isUndefined from 'lodash/isUndefined'; +import omitBy from 'lodash/omitBy'; + +import { fillPathTemplate } from 'utils'; + +import { GenericApiGatewayContract } from '../ApiGatewayContract'; +import { BodyType, RequestArguments, RequestParameters } from '../types'; + +export const getRequestParameters = < + Contract extends GenericApiGatewayContract, +>( + contract: Contract, + requestArguments: RequestArguments, +): RequestParameters> => { + // TODO improve inner typing here + const { pathParameters, queryStringParameters, headers, body } = + requestArguments as { + pathParameters: Record; + queryStringParameters: Record; + headers: Record; + body: unknown; // we cast at the return of the function anyway + }; + + const path = + typeof pathParameters !== 'undefined' + ? fillPathTemplate(contract.path, pathParameters) + : contract.path; + + return omitBy( + { + method: contract.method, + path, + body, + queryStringParameters: omitBy( + queryStringParameters, + isUndefined, + ) as Record, + headers: { ...headers, 'Content-Type': 'application/json' }, + }, + isUndefined, + ) as unknown as RequestParameters>; +}; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/features/utils.ts b/packages/aws-zod-interface-contracts/src/apiGateway/features/utils.ts new file mode 100644 index 0000000..af9f57b --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/apiGateway/features/utils.ts @@ -0,0 +1,8 @@ +export const combineUrls = (path: string, baseUrl: string | URL): URL => { + const stringBaseUrl = baseUrl instanceof URL ? baseUrl.toString() : baseUrl; + + const pathWithoutLeadingSlash = path.replace(/^\/+/, ''); + const baseUrlWithTrailingSlash = stringBaseUrl.replace(/\/+$/, '') + '/'; + + return new URL(pathWithoutLeadingSlash, baseUrlWithTrailingSlash); +}; diff --git a/packages/aws-zod-interface-contracts/src/apiGateway/types.ts b/packages/aws-zod-interface-contracts/src/apiGateway/types.ts index e6cccbe..d69cdf7 100644 --- a/packages/aws-zod-interface-contracts/src/apiGateway/types.ts +++ b/packages/aws-zod-interface-contracts/src/apiGateway/types.ts @@ -1,7 +1,12 @@ import { O } from 'ts-toolbelt'; import { z } from 'zod'; -import { ConstrainedJsonZodSchema, JsonZodSchema } from 'types'; +import { + ConstrainedJsonZodSchema, + DefinedProperties, + JsonZodSchema, +} from 'types'; +import { HttpMethod } from 'types/http'; import { GenericApiGatewayContract } from './ApiGatewayContract'; @@ -44,3 +49,22 @@ export type OutputsType = { export type OutputType = O.UnionOf< OutputsType >; + +/** + * Computed request parameters. This enables the call to the contract to be type-safe + */ +export interface RequestParameters { + method: HttpMethod; + path: string; + body?: RequestBodyType; + headers?: Record; + queryStringParameters?: Record; +} + +export type RequestArguments = + DefinedProperties<{ + pathParameters: PathParametersType; + queryStringParameters: QueryStringParametersType; + headers: HeadersType; + body: BodyType; + }>; diff --git a/packages/aws-zod-interface-contracts/src/types/contractOpenApiDocumentation.ts b/packages/aws-zod-interface-contracts/src/types/contractOpenApiDocumentation.ts new file mode 100644 index 0000000..4663c61 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/types/contractOpenApiDocumentation.ts @@ -0,0 +1,7 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export interface ContractOpenApiDocumentation { + path: string; + method: string; + documentation: OpenAPIV3.OperationObject; +} diff --git a/packages/aws-zod-interface-contracts/src/types/jsonZodSchema.ts b/packages/aws-zod-interface-contracts/src/types/jsonZodSchema.ts index 33e1211..2e93b18 100644 --- a/packages/aws-zod-interface-contracts/src/types/jsonZodSchema.ts +++ b/packages/aws-zod-interface-contracts/src/types/jsonZodSchema.ts @@ -11,6 +11,6 @@ type Literal = z.infer; type Json = Literal | { [key: string]: Json } | Json[]; export type JsonZodSchema = z.ZodType; -const constrainedJsonZodSchema = z.record(z.union([z.string(), z.unknown()])); -type Constrained = z.infer; -export type ConstrainedJsonZodSchema = z.ZodType; +export type ConstrainedJsonZodSchema = z.ZodObject<{ + [x: string]: z.ZodString | z.ZodOptional; +}>; diff --git a/packages/aws-zod-interface-contracts/src/utils.ts b/packages/aws-zod-interface-contracts/src/utils.ts new file mode 100644 index 0000000..498d883 --- /dev/null +++ b/packages/aws-zod-interface-contracts/src/utils.ts @@ -0,0 +1,21 @@ +/** + * Fills a string template with values. + * + * The keys to be replaced must be passed in `{}` in the template. For example `"/users/{userId}"`"; + * The key name must match the one passed in the curly braces + * + * @param template the template to be fill; + * @param values the values to fill; + * @returns the filled template. + */ +export const fillPathTemplate = ( + template: string, + values?: Record, +): string => + values === undefined + ? template + : Object.entries(values).reduce((accumulator, [key, value]) => { + const re = new RegExp(`{${key}}`, 'g'); + + return accumulator.replace(re, value); + }, template); diff --git a/packages/aws-zod-interface-contracts/tsconfig.build.json b/packages/aws-zod-interface-contracts/tsconfig.build.json index 88de26e..e057c3f 100644 --- a/packages/aws-zod-interface-contracts/tsconfig.build.json +++ b/packages/aws-zod-interface-contracts/tsconfig.build.json @@ -11,7 +11,6 @@ "./vite*", "./dist", "./tsup.config.ts", - "./**/__benches__/**/*", "./**/__mocks__/**/*", "./**/__tests__/**/*" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a82458..d898e45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,19 +65,25 @@ importers: packages/aws-zod-interface-contracts: dependencies: + '@anatine/zod-openapi': + specifier: ^2.2.1 + version: 2.2.1(openapi3-ts@4.1.2)(zod@3.22.4) '@types/http-errors': specifier: ^2.0.1 version: 2.0.3 http-errors: specifier: ^2.0.0 version: 2.0.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 ts-toolbelt: specifier: ^9.6.0 version: 9.6.0 devDependencies: - '@codspeed/tinybench-plugin': - specifier: ^2.2.0 - version: 2.2.0(tinybench@2.5.0) '@types/aws-lambda': specifier: ^8.10.125 version: 8.10.125 @@ -90,6 +96,9 @@ importers: '@vitest/coverage-c8': specifier: 0.30.0 version: 0.30.0(vitest@0.31.1) + axios: + specifier: ^1.2.2 + version: 1.3.3 concurrently: specifier: ^8.0.0 version: 8.0.0 @@ -105,9 +114,6 @@ importers: rimraf: specifier: ^5.0.0 version: 5.0.0 - tinybench: - specifier: ^2.3.1 - version: 2.5.0 ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.16.1)(typescript@5.0.2) @@ -345,6 +351,17 @@ packages: '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.19 + /@anatine/zod-openapi@2.2.1(openapi3-ts@4.1.2)(zod@3.22.4): + resolution: {integrity: sha512-tYWsCc82II3XMR8lJgg8+AHgCbfsKpgzDmcceLW1SRpqiueqYVGW5AE33W4LJTl+WJ2/mET0DWD17biFO7k/Qg==} + peerDependencies: + openapi3-ts: ^4.1.2 + zod: ^3.20.0 + dependencies: + openapi3-ts: 4.1.2 + ts-deepmerge: 6.2.0 + zod: 3.22.4 + dev: false + /@antfu/utils@0.7.6: resolution: {integrity: sha512-pvFiLP2BeOKA/ZOS6jxx4XhKzdVLHDhGlFEaZ2flWWYf2xOqVniqpk38I04DFRyz+L0ASggl7SkItTc+ZLju4w==} dev: true @@ -1606,23 +1623,6 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true - /@codspeed/core@2.2.0: - resolution: {integrity: sha512-GSbTPA5Vt7rsrTenP/08zC55Ob2ag8AmqH9BfnoJqDv5RyHDv6tIHkJMLPuqatcKGFbuxbpE/FSYFE2xKkvqRQ==} - dependencies: - node-gyp-build: 4.6.0 - dev: true - - /@codspeed/tinybench-plugin@2.2.0(tinybench@2.5.0): - resolution: {integrity: sha512-yxOFhsJoICWqQ0e6I+S4CitA9gBDARHNYko04jyw9wnaCdFLc0uamVJI7p+BMmqa+1q3uvET3yUBHf6sEe/B1Q==} - peerDependencies: - tinybench: ^2.3.0 - dependencies: - '@codspeed/core': 2.2.0 - find-up: 6.3.0 - stack-trace: 1.0.0-pre2 - tinybench: 2.5.0 - dev: true - /@colors/colors@1.5.0: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -7452,14 +7452,6 @@ packages: locate-path: 6.0.0 path-exists: 4.0.0 - /find-up@6.3.0: - resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - locate-path: 7.1.1 - path-exists: 5.0.0 - dev: true - /flat-cache@3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -9257,13 +9249,6 @@ packages: dependencies: p-locate: 5.0.0 - /locate-path@7.1.1: - resolution: {integrity: sha512-vJXaRMJgRVD3+cUZs3Mncj2mxpt5mP0EmNOsxRSZRMlbqjvxzDEOIUWXGmavo0ZC9+tNZCBLQ66reA11nbpHZg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - p-locate: 6.0.0 - dev: true - /lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} dev: true @@ -10306,6 +10291,16 @@ packages: is-wsl: 2.2.0 dev: true + /openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + dev: false + + /openapi3-ts@4.1.2: + resolution: {integrity: sha512-B7gOkwsYMZO7BZXwJzXCuVagym2xhqsrilVvV0dnq2Di4+iLUXKVX9gOK23ZqaAHZOwABXN0QTdW8QnkUTX6DA==} + dependencies: + yaml: 2.3.3 + dev: false + /opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -10401,13 +10396,6 @@ packages: dependencies: p-limit: 3.1.0 - /p-locate@6.0.0: - resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - p-limit: 4.0.0 - dev: true - /p-map-series@2.1.0: resolution: {integrity: sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q==} engines: {node: '>=8'} @@ -10593,11 +10581,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - /path-exists@5.0.0: - resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dev: true - /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -12388,11 +12371,6 @@ packages: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' - /stack-trace@1.0.0-pre2: - resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} - engines: {node: '>=16'} - dev: true - /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true @@ -12882,6 +12860,11 @@ packages: /trough@1.0.5: resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} + /ts-deepmerge@6.2.0: + resolution: {integrity: sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==} + engines: {node: '>=14.13.1'} + dev: false + /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true @@ -14038,7 +14021,6 @@ packages: /yaml@2.3.3: resolution: {integrity: sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==} engines: {node: '>= 14'} - dev: true /yargs-parser@20.2.4: resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} @@ -14097,7 +14079,6 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - dev: true /zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}