From ec52215e5de1c047d98d30390e9b8555bb603fcf Mon Sep 17 00:00:00 2001 From: Stanislav Muhametsin <346799+stazz@users.noreply.github.com> Date: Sat, 20 Jan 2024 11:42:58 +0200 Subject: [PATCH 1/2] =?UTF-8?q?#124=20Adding=20API=20and=20implementation?= =?UTF-8?q?=20to=20specify=20end=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …points without decorators. --- endpoint-spec/package.json | 2 +- endpoint-spec/src/__test__/inline.spec.ts | 18 ++ endpoint-spec/src/__test__/inline.ts | 61 ++++ endpoint-spec/src/api.types/app.types.ts | 32 ++- endpoint-spec/src/api.types/common.types.ts | 7 + endpoint-spec/src/api.types/url.types.ts | 115 +++++++- endpoint-spec/src/implementation/app.ts | 295 ++++++++++++++++---- 7 files changed, 447 insertions(+), 83 deletions(-) create mode 100644 endpoint-spec/src/__test__/inline.spec.ts create mode 100644 endpoint-spec/src/__test__/inline.ts diff --git a/endpoint-spec/package.json b/endpoint-spec/package.json index c3d7f7c..3b920b2 100644 --- a/endpoint-spec/package.json +++ b/endpoint-spec/package.json @@ -1,6 +1,6 @@ { "name": "@ty-ras/endpoint-spec", - "version": "2.0.1", + "version": "2.1.0", "author": { "name": "Stanislav Muhametsin", "email": "346799+stazz@users.noreply.github.com", diff --git a/endpoint-spec/src/__test__/inline.spec.ts b/endpoint-spec/src/__test__/inline.spec.ts new file mode 100644 index 0000000..be0baf2 --- /dev/null +++ b/endpoint-spec/src/__test__/inline.spec.ts @@ -0,0 +1,18 @@ +/** + * @file This file contains tests for the endpoint builder using definitions in "./inline.ts" file. + */ + +import test from "ava"; +import * as epValidation from "./endpoint-validation"; +import inlineEndpoints, { SEEN_ARGS } from "./inline"; + +test("Test that decorator-based builder works on class with instance methods", async (c) => { + c.plan(6); + const { endpoints } = inlineEndpoints; + c.deepEqual( + endpoints.length, + 1, + "There must be exactly one endpoint created by application builder.", + ); + await epValidation.validateEndpoint(c, endpoints[0], () => SEEN_ARGS); +}); diff --git a/endpoint-spec/src/__test__/inline.ts b/endpoint-spec/src/__test__/inline.ts new file mode 100644 index 0000000..d8b10fd --- /dev/null +++ b/endpoint-spec/src/__test__/inline.ts @@ -0,0 +1,61 @@ +/** + * @file This file contains the inline implementation for TyRAS-powered app, without using decorators. + */ + +import type * as spec from ".."; +import * as mp from "./missing-parts"; +import * as protocol from "./protocol"; + +/* eslint-disable jsdoc/require-jsdoc */ + +export const app = mp.newBuilder({}); +type StateSpecBase = spec.StateSpecBaseOfAppBuilder; + +const withURL = app.url`/something/${mp.urlParameter( + "urlParam", + protocol.urlParam, +)}`({}); +const stateSpec = { + userId: false, +} as const satisfies StateSpecBase; + +const endpoint = withURL.endpoint({})( + { + method: "GET", + responseBody: mp.responseBody(protocol.responseBody), + query: mp.query({ + queryParam: { + decoder: protocol.queryParam, + required: false, + }, + }), + responseHeaders: mp.responseHeaders({ + responseHeader: { + encoder: protocol.resHeader, + required: true, + }, + }), + requestBody: app.requestBody(protocol.requestBody), + state: stateSpec, + }, + (args) => { + SEEN_ARGS.push(args); + return { + body: "responseBody", + headers: { + responseHeader: "resHeader", + }, + } as const; + }, +); + +export const SEEN_ARGS: Array< + spec.GetMethodArgs +> = []; + +export default app.createEndpoints( + {}, + { + "/api": endpoint, + }, +); diff --git a/endpoint-spec/src/api.types/app.types.ts b/endpoint-spec/src/api.types/app.types.ts index ee72276..2b95d09 100644 --- a/endpoint-spec/src/api.types/app.types.ts +++ b/endpoint-spec/src/api.types/app.types.ts @@ -47,9 +47,7 @@ export interface ApplicationBuilderGeneric< * @param args The non-string portions of the template literal. * @returns A new {@link url.ApplicationEndpointsForURLFactory} to be used to decorate class methods using ES decorators. */ - url: < - TArgs extends Array>, - >( + url: >( this: void, fragments: TemplateStringsArray, ...args: TArgs @@ -64,7 +62,7 @@ export interface ApplicationBuilderGeneric< TDefaultRequestBodyContentType, TDefaultResponseBodyContentType, TEndpointSpecAdditionalDataHKT, - TArgs extends [] ? undefined : URLParameterReducer + GetURLData >; /** @@ -160,6 +158,23 @@ export interface ApplicationBuilderGeneric< >; } +/** + * This is the base type for he arguments given to template literal function. + * @see ApplicationBuilderGeneric.url + */ +export type TURLTemplateLiteralArgsBase< + TValidatorHKT extends data.ValidatorHKTBase, +> = Array>; + +/** + * Helper type to extract the shape of the URL parameters from the arguments given to template literal function. + * @see ApplicationBuilderGeneric.url + */ +export type GetURLData< + TValidatorHKT extends data.ValidatorHKTBase, + TArgs extends TURLTemplateLiteralArgsBase, +> = TArgs extends [] ? undefined : URLParameterReducer; + /** * Helper type to extract final type of URL parameters, given an array of {@link URLParameterInfo} objects. * Modified from [StackOverflow](https://stackoverflow.com/questions/69085499/typescript-convert-tuple-type-to-object). @@ -267,14 +282,7 @@ export type EndpointCreationArg = * @see EndpointCreationArgLeafSingle */ export type EndpointCreationArgLeaf = - data.OneOrMany; - -/** - * This is single atom of {@link EndpointCreationArgLeaf} type. - * It represents either class, or objects created from classes using `new` operator. - * Since TypeScript does not allow easily to describe such nuances, it is for now just `object` type, to avoid at least the most obvious compilation errors. - */ -export type EndpointCreationArgLeafSingle = object; + data.OneOrMany; /** * This type is part of {@link EndpointCreationArg}. diff --git a/endpoint-spec/src/api.types/common.types.ts b/endpoint-spec/src/api.types/common.types.ts index 8e94bb2..adefa4c 100644 --- a/endpoint-spec/src/api.types/common.types.ts +++ b/endpoint-spec/src/api.types/common.types.ts @@ -49,3 +49,10 @@ export type MaterializeEndpointSpecAdditionalData< readonly _argStateSpec: TStateSpec; })["_getAdditionalEndpointSpecData"] : never; + +/** + * This is single atom of {@link EndpointCreationArgLeaf} type. + * It represents either class, or objects created from classes using `new` operator. + * Since TypeScript does not allow easily to describe such nuances, it is for now just `object` type, to avoid at least the most obvious compilation errors. + */ +export type EndpointCreationArgLeafSingle = object; diff --git a/endpoint-spec/src/api.types/url.types.ts b/endpoint-spec/src/api.types/url.types.ts index b9971d0..581e0fd 100644 --- a/endpoint-spec/src/api.types/url.types.ts +++ b/endpoint-spec/src/api.types/url.types.ts @@ -93,15 +93,14 @@ export interface ApplicationEndpointsForURL< TRequestBodyContentType extends TAllRequestBodyContentTypes = TDefaultRequestBodyContentType, >( this: void, - mdArgs: { - [P in keyof TMetadataProviders]: md.MaterializeParameterWhenSpecifyingEndpoint< - TMetadataProviders[P], - TProtoEncodedHKT, - TProtocolSpec, - TRequestBodyContentType, - TResponseBodyContentType - >; - }, + mdArgs: GetEndpointMetadataArgs< + TProtoEncodedHKT, + TMetadataProviders, + TURLData, + TProtocolSpec, + TRequestBodyContentType, + TResponseBodyContentType + >, ): ClassMethodDecoratorFactory< TProtoEncodedHKT, TValidatorHKT, @@ -112,8 +111,106 @@ export interface ApplicationEndpointsForURL< TEndpointSpecAdditionalData, TProtocolSpec >; + + /** + * Allows endpoint to be added without using decorators. + * Useful when the toolchain does not support decorator functionality, and/or additional scope provided by the classes (e.g. with properties) is not required. + * + * The returned {@link AddEndpointAsInline} will need further invocation to provide actual implementation. + * This is in order to avoid specifying generic parameter twice. + * @param this The `this` parameter is `void` to prevent using "this" in implementations. + * @param mdArgs Parameters for each of the metadata providers. Each parameter is related to this specific BE endpoint. + * @returns The {@link AddEndpointAsInline} to continue adding the endpoint. + */ + endpoint: < + TProtocolSpec extends GetProtocolBaseForURLData, + TResponseBodyContentType extends TAllResponseBodyContentTypes = TDefaultResponseBodyContentType, + TRequestBodyContentType extends TAllRequestBodyContentTypes = TDefaultRequestBodyContentType, + >( + this: void, + mdArgs: GetEndpointMetadataArgs< + TProtoEncodedHKT, + TMetadataProviders, + TURLData, + TProtocolSpec, + TRequestBodyContentType, + TResponseBodyContentType + >, + ) => AddEndpointAsInline< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TServerContext, + TRequestBodyContentType, + TResponseBodyContentType, + TEndpointSpecAdditionalData, + TProtocolSpec + >; } +/** + * Helper type to define metadata arguments for one specific endpoint (url pattern + method combination). + */ +export type GetEndpointMetadataArgs< + TProtoEncodedHKT extends protocol.EncodedHKTBase, + TMetadataProviders extends common.TMetadataProvidersBase, + TURLData, + TProtocolSpec extends GetProtocolBaseForURLData, + TRequestBodyContentType extends string, + TResponseBodyContentType extends string, +> = { + [P in keyof TMetadataProviders]: md.MaterializeParameterWhenSpecifyingEndpoint< + TMetadataProviders[P], + TProtoEncodedHKT, + TProtocolSpec, + TRequestBodyContentType, + TResponseBodyContentType + >; +}; + +/** + * This type exposes functionality to add endpoint implementation without decorators. + */ +export interface AddEndpointAsInline< + TProtoEncodedHKT extends protocol.EncodedHKTBase, + TValidatorHKT extends data.ValidatorHKTBase, + TStateHKT extends dataBE.StateHKTBase, + TServerContext, + TRequestBodyContentType extends string, + TResponseBodyContentType extends string, + TEndpointSpecAdditionalData extends common.EndpointSpecAdditionalDataHKTBase, + TProtocolSpec extends protocol.ProtocolSpecCore, +> { + /** + * Registers the endpoint to the application. + * @param this The `this` parameter is `void` to prevent using "this" in implementations. + * @param spec The endpoint specification. + * @param implementation The endpoint implementation. + * @returns Nothing. + */ + >( + this: void, + spec: GetEndpointSpec< + TProtoEncodedHKT, + TValidatorHKT, + TRequestBodyContentType, + TResponseBodyContentType, + TEndpointSpecAdditionalData, + TProtocolSpec, + TStateSpec + >, + implementation: MethodForEndpoint< + GetMethodArgsGeneric< + TStateHKT, + TServerContext, + TProtocolSpec, + TStateSpec + >, + void, + GetMethodReturnType + >, + ): common.EndpointCreationArgLeafSingle; +} /** * This is function interface to create the decorator for class methods acting as BE endpoints. * diff --git a/endpoint-spec/src/implementation/app.ts b/endpoint-spec/src/implementation/app.ts index f22a454..c6d8ad7 100644 --- a/endpoint-spec/src/implementation/app.ts +++ b/endpoint-spec/src/implementation/app.ts @@ -207,73 +207,76 @@ const newBuilderGenericImpl = < patternSpec: Array.from(generateURLPattern(fragments, args)), }; urlStates.push(urlState); - return (mdArgs) => (specArg) => (method, context) => { - const spec = specArg as api.GetEndpointSpec< - TProtoEncodedHKT, - TValidatorHKT, - string, - string, - TEndpointSpecAdditionalDataHKT, - protocol.ProtocolSpecCore & - protocol.ProtocolSpecQuery & - protocol.ProtocolSpecRequestBody & - protocol.ProtocolSpecHeaderData & - protocol.ProtocolSpecResponseHeaders, - any - >; - if (spec.method in urlState.specsAndMetadatas) { - throw new Error( - `Can not define different endpoints for same method "${spec.method}".`, - ); - } - // We have to do this to get typing right - // Save current state - the initializer might run only after constuctor, if not static method - const currentEndpointState: InternalStateForEndpointMethod< + // Implementation for decorators + function endpointsForURL< + TProtocolSpec extends api.GetProtocolBaseForURLData< + api.GetURLData + >, + TResponseBodyContentType extends TAllResponseBodyContentTypes, + TRequestBodyContentType extends TAllRequestBodyContentTypes, + >( + this: void, + mdArgs: api.GetEndpointMetadataArgs< TProtoEncodedHKT, - TValidatorHKT, - TStateHKT, TMetadataProviders, - InternalRuntimeInfoForClasses - > = { - metadata: { - spec: { - method: spec.method, - responseBody: spec.responseBody.validatorSpec, - query: spec.query?.metadata, - requestBody: spec.requestBody?.validatorSpec, - requestHeaders: spec.headers?.metadata, - responseHeaders: spec.responseHeaders?.metadata, - stateInfo: fromStateSpec(spec.state), - }, + api.GetURLData, + TProtocolSpec, + TRequestBodyContentType, + TResponseBodyContentType + >, + ): api.ClassMethodDecoratorFactory< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TServerContext, + TRequestBodyContentType, + TResponseBodyContentType, + TEndpointSpecAdditionalDataHKT, + TProtocolSpec + > { + return (specArg) => (method, context) => { + addEndpointImplementation( + fromStateSpec, + processMethod, + urlState, mdArgs, - }, - runtime: { - handlerInfo: { - queryValidator: spec.query?.validators, - bodyValidator: spec.requestBody?.validator, - headerValidator: spec.headers?.validators, - }, - responseInfo: { - body: spec.responseBody.validator, - headers: spec.responseHeaders?.validators, - }, - instances: [], - }, + specArg, + { method, context }, + ); }; - urlState.specsAndMetadatas[spec.method] = currentEndpointState; - context.addInitializer(function () { - const boundMethod = method.bind(this); - currentEndpointState.runtime.instances.push({ - instance: this, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment - boundMethod: (processMethod({ - spec, - boundMethod, - } as any) ?? boundMethod) as any, - }); - }); + } + + // Implementation for inline endpoints + const endpoint: api.ApplicationEndpointsForURL< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TMetadataProviders, + TServerContext, + TAllRequestBodyContentTypes, + TAllResponseBodyContentTypes, + TDefaultRequestBodyContentType, + TDefaultResponseBodyContentType, + TEndpointSpecAdditionalDataHKT, + api.GetURLData + >["endpoint"] = (mdArgs) => (specArg, implementation) => { + const instance = new InlineEndpoint(); + addEndpointImplementation( + fromStateSpec, + processMethod, + urlState, + mdArgs, + specArg, + { + method: implementation, + instance, + }, + ); + return instance; }; + endpointsForURL.endpoint = endpoint; + return endpointsForURL; }; }, createEndpoints: (mdArgs, ...args) => { @@ -674,3 +677,173 @@ const getActualPatternSpec = ( } return patternSpec; }; + +const addEndpointImplementation = < + TProtoEncodedHKT extends protocol.EncodedHKTBase, + TValidatorHKT extends data.ValidatorHKTBase, + TStateHKT extends dataBE.StateHKTBase, + TMetadataProviders extends api.TMetadataProvidersBase, + TServerContext, + TAllRequestBodyContentTypes extends string, + TAllResponseBodyContentTypes extends string, + TEndpointSpecAdditionalDataHKT extends api.EndpointSpecAdditionalDataHKTBase, + TArgs extends api.TURLTemplateLiteralArgsBase, + TProtocolSpec extends api.GetProtocolBaseForURLData< + api.GetURLData + >, + TResponseBodyContentType extends TAllResponseBodyContentTypes, + TRequestBodyContentType extends TAllRequestBodyContentTypes, + TStateSpec extends dataBE.MaterializeStateSpecBase, + This extends object, +>( + fromStateSpec: EndpointStateInformationFromStateSpec, + processMethod: api.EndpointMethodProcessor< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TServerContext, + TAllRequestBodyContentTypes, + TAllResponseBodyContentTypes, + TEndpointSpecAdditionalDataHKT + >, + urlState: InternalStateForURL< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TMetadataProviders, + InternalRuntimeInfoForClasses + >, + mdArgs: api.GetEndpointMetadataArgs< + TProtoEncodedHKT, + TMetadataProviders, + api.GetURLData, + TProtocolSpec, + TRequestBodyContentType, + TResponseBodyContentType + >, + specArg: api.GetEndpointSpec< + TProtoEncodedHKT, + TValidatorHKT, + TRequestBodyContentType, + TResponseBodyContentType, + TEndpointSpecAdditionalDataHKT, + TProtocolSpec, + TStateSpec + >, + methodInfo: + | { + method: api.MethodForEndpoint< + api.GetMethodArgsGeneric< + TStateHKT, + TServerContext, + TProtocolSpec, + TStateSpec + >, + This, + api.GetMethodReturnType + >; + context: ClassMethodDecoratorContext< + This, + api.MethodForEndpoint< + api.GetMethodArgsGeneric< + TStateHKT, + TServerContext, + TProtocolSpec, + TStateSpec + >, + This, + api.GetMethodReturnType + > + >; + } + | { + method: api.MethodForEndpoint< + api.GetMethodArgsGeneric< + TStateHKT, + TServerContext, + TProtocolSpec, + TStateSpec + >, + void, + api.GetMethodReturnType + >; + instance: This; + }, +) => { + const spec = specArg as api.GetEndpointSpec< + TProtoEncodedHKT, + TValidatorHKT, + string, + string, + TEndpointSpecAdditionalDataHKT, + protocol.ProtocolSpecCore & + protocol.ProtocolSpecQuery & + protocol.ProtocolSpecRequestBody & + protocol.ProtocolSpecHeaderData & + protocol.ProtocolSpecResponseHeaders, + any + >; + if (spec.method in urlState.specsAndMetadatas) { + throw new Error( + `Can not define different endpoints for same method "${spec.method}".`, + ); + } + + // We have to do this to get typing right + // Save current state - the initializer might run only after constuctor, if not static method + const currentEndpointState: InternalStateForEndpointMethod< + TProtoEncodedHKT, + TValidatorHKT, + TStateHKT, + TMetadataProviders, + InternalRuntimeInfoForClasses + > = { + metadata: { + spec: { + method: spec.method, + responseBody: spec.responseBody.validatorSpec, + query: spec.query?.metadata, + requestBody: spec.requestBody?.validatorSpec, + requestHeaders: spec.headers?.metadata, + responseHeaders: spec.responseHeaders?.metadata, + stateInfo: fromStateSpec(spec.state), + }, + mdArgs, + }, + runtime: { + handlerInfo: { + queryValidator: spec.query?.validators, + bodyValidator: spec.requestBody?.validator, + headerValidator: spec.headers?.validators, + }, + responseInfo: { + body: spec.responseBody.validator, + headers: spec.responseHeaders?.validators, + }, + instances: [], + }, + }; + urlState.specsAndMetadatas[spec.method] = currentEndpointState; + if ("context" in methodInfo) { + const { method, context } = methodInfo; + context.addInitializer(function () { + const boundMethod = method.bind(this); + currentEndpointState.runtime.instances.push({ + instance: this, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment + boundMethod: (processMethod({ + spec, + boundMethod, + } as any) ?? boundMethod) as any, + }); + }); + } else { + const { method, instance } = methodInfo; + currentEndpointState.runtime.instances.push({ + instance, + boundMethod: method, + }); + } +}; + +class InlineEndpoint {} From e6d9caed6414971dc3ae3da361bdc36a23bcd06b Mon Sep 17 00:00:00 2001 From: Stanislav Muhametsin <346799+stazz@users.noreply.github.com> Date: Sat, 20 Jan 2024 11:44:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?#124=20Node=2018.19=20introduced=20change?= =?UTF-8?q?=20which=20breaks=20ts=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …-node, so use 18.18.2 for now. --- versions/node | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions/node b/versions/node index 25bf17f..a58d2d2 100644 --- a/versions/node +++ b/versions/node @@ -1 +1 @@ -18 \ No newline at end of file +18.18.2 \ No newline at end of file