diff --git a/packages/aws-lambda-rest/src/handler.ts b/packages/aws-lambda-rest/src/handler.ts index 70c09d3e..e1b5e91d 100644 --- a/packages/aws-lambda-rest/src/handler.ts +++ b/packages/aws-lambda-rest/src/handler.ts @@ -10,14 +10,14 @@ import { getAbsoluteFSPath } from 'swagger-ui-dist' export type Context = { lambdaApi: { request: Request; response: Response } } -export function build({ +export function build({ api, context, error, customize, ...args }: { - api: rest.Api + api: rest.Api context: (serverContext: Context) => Promise error?: rest.ErrorHandler options: Partial diff --git a/packages/aws-lambda-rest/src/methods.ts b/packages/aws-lambda-rest/src/methods.ts index d90f7e25..7fa66ba3 100644 --- a/packages/aws-lambda-rest/src/methods.ts +++ b/packages/aws-lambda-rest/src/methods.ts @@ -4,14 +4,14 @@ import { rest, utils } from '@mondrian-framework/rest' import { http, isArray } from '@mondrian-framework/utils' import { API, HandlerFunction, METHODS } from 'lambda-api' -export function attachRestMethods({ +export function attachRestMethods({ server, api, context, error, }: { server: API - api: rest.Api + api: rest.Api context: (serverContext: Context) => Promise error?: rest.ErrorHandler }): void { @@ -29,14 +29,14 @@ export function attachRestMethods p.replace(/{(.*?)}/g, ':$1')) - const restHandler = rest.handler.fromFunction({ + const restHandler = rest.handler.fromFunction({ module: api.module, context, specification, functionName, functionBody, error, - api, + api: api as any, }) const lambdaApiHandler: HandlerFunction = async (request, response) => { const result = await restHandler({ diff --git a/packages/aws-lambda-sqs/src/handler.ts b/packages/aws-lambda-sqs/src/handler.ts index 03b583ea..edaadcb4 100644 --- a/packages/aws-lambda-sqs/src/handler.ts +++ b/packages/aws-lambda-sqs/src/handler.ts @@ -20,12 +20,12 @@ type FunctionSpecifications = { | { anyQueue: true } ) -export function build({ +export function build({ module, api, context, }: { - module: module.Module + module: module.Module api: Api context: (args: { event: SQSEvent; context: Context; recordIndex: number }) => Promise }): SQSHandler { diff --git a/packages/aws-sqs/src/api.ts b/packages/aws-sqs/src/api.ts index 77873174..4d5d62d3 100644 --- a/packages/aws-sqs/src/api.ts +++ b/packages/aws-sqs/src/api.ts @@ -22,8 +22,8 @@ export type ApiSpecification = { * attach SQS to any functions and consuming it's messages. * In order to instantiate this you should use {@link build}. */ -export type Api = ApiSpecification & { - module: module.Module +export type Api = ApiSpecification & { + module: module.Module } export type FunctionSpecifications = { @@ -35,7 +35,9 @@ export type FunctionSpecifications = { /** * Builds a SQS API in order to attach the module to the queues. */ -export function build(api: Api): Api { +export function build( + api: Api, +): Api { //TODO [Good first issue]: check validity of api as rest.build return api } diff --git a/packages/aws-sqs/src/listener.ts b/packages/aws-sqs/src/listener.ts index b07c955d..947a7872 100644 --- a/packages/aws-sqs/src/listener.ts +++ b/packages/aws-sqs/src/listener.ts @@ -7,11 +7,11 @@ import { sleep } from '@mondrian-framework/utils' /** * TODO: doc */ -export function listen({ +export function listen({ api, context, }: { - api: Api + api: Api context: (args: { message: AWS.Message }) => Promise }): { close: () => Promise } { const client: AWS.SQS = new AWS.SQS(api.options?.config ?? {}) @@ -48,7 +48,7 @@ export function listen({ } } -async function listenForMessage({ +async function listenForMessage({ alive, queueUrl, client, @@ -61,7 +61,7 @@ async function listenForMessage( queueUrl: string alive: { yes: boolean } client: AWS.SQS - module: module.Module + module: module.Module functionName: string context: (args: { message: AWS.Message }) => Promise specifications: FunctionSpecifications diff --git a/packages/ci-tools/src/impl/module.ts b/packages/ci-tools/src/impl/module.ts index 7b2c7882..ba2bd5a7 100644 --- a/packages/ci-tools/src/impl/module.ts +++ b/packages/ci-tools/src/impl/module.ts @@ -3,6 +3,7 @@ import { moduleInterface } from '../interface' import { buildGraphQLReport } from './build-graphql-report' import { buildOASReport } from './build-oas-report' import { getReport } from './get-report' +import { result } from '@mondrian-framework/model' export type Context = { readonly fileManager: FileManager @@ -10,10 +11,11 @@ export type Context = { } export const module = moduleInterface.implement({ - context: async () => ({ - fileManager: process.env.BUCKET ? S3_FILE_MANAGER : LOCAL_FILE_MANAGER, - serverBaseURL: process.env.SERVER_BASE_URL ?? 'http://localhost:4010', - }), + context: async () => + result.ok({ + fileManager: process.env.BUCKET ? S3_FILE_MANAGER : LOCAL_FILE_MANAGER, + serverBaseURL: process.env.SERVER_BASE_URL ?? 'http://localhost:4010', + }), functions: { getReport, buildOASReport, buildGraphQLReport }, options: { checkOutputType: 'throw' }, }) diff --git a/packages/cron/src/api.ts b/packages/cron/src/api.ts index 95acda76..11e5f2be 100644 --- a/packages/cron/src/api.ts +++ b/packages/cron/src/api.ts @@ -18,8 +18,8 @@ export type ApiSpecification = { * schedule functions execution with a cron string. * In order to instantiate this you should use {@link build}. */ -export type Api = ApiSpecification & { - module: module.Module +export type Api = ApiSpecification & { + module: module.Module } export type FunctionSpecifications = { @@ -37,7 +37,9 @@ type InputGenerator /** * Builds a cron API in order to schedule function execution. */ -export function build(api: Api): Api { +export function build( + api: Api, +): Api { //TODO [Good first issue]: check validity of api as rest.build return api } diff --git a/packages/cron/src/executor.ts b/packages/cron/src/executor.ts index 3b008b72..b42627f4 100644 --- a/packages/cron/src/executor.ts +++ b/packages/cron/src/executor.ts @@ -9,11 +9,11 @@ import { schedule, validate } from 'node-cron' * Starts a new cron listeners with the given configuration. * For each cron assigned function a new schedule is created. */ -export function start({ +export function start({ api, context, }: { - api: Api + api: Api context: (args: { cron: string }) => Promise }): { close: () => Promise } { const baseLogger = logger.build({ moduleName: api.module.name, server: 'CRON' }) diff --git a/packages/direct/src/api.ts b/packages/direct/src/api.ts index 0e83562a..10acc7dd 100644 --- a/packages/direct/src/api.ts +++ b/packages/direct/src/api.ts @@ -25,18 +25,22 @@ export type ApiSpecification< */ export type Api< Fs extends functions.Functions, + E extends functions.ErrorType, Exclusions extends { [K in keyof Fs]?: true }, ContextInput, > = ApiSpecification & { - module: module.Module + module: module.Module } /** * Builds a Direct API in order to expose the module. */ -export function build( - api: Api, -): Api { +export function build< + Fs extends functions.Functions, + E extends functions.ErrorType, + Exclusions extends { [K in keyof Fs]?: true }, + ContextInput, +>(api: Api): Api { //assertApiValidity(api) //TODO [Good first issue]: as rest.assertApiValidity return api } diff --git a/packages/direct/src/handler.ts b/packages/direct/src/handler.ts index afc52da4..87f99a79 100644 --- a/packages/direct/src/handler.ts +++ b/packages/direct/src/handler.ts @@ -40,12 +40,12 @@ export type Response = SuccessResponse | FailureResponse /** * Gets an http handler with the implementation of the Direct transport for a whole Mondrian module. */ -export function fromModule({ +export function fromModule({ api, context, options, }: { - api: Api + api: Api context: (serverContext: ServerContext, metadata: Record | undefined) => Promise options: ServeOptions }): http.Handler { @@ -103,7 +103,12 @@ export function fromModule({ +async function handleFunctionCall< + Fs extends functions.Functions, + E extends functions.ErrorType, + ServerContext, + ContextInput, +>({ functionName, tracer, requestInputTypeMap, @@ -118,7 +123,7 @@ async function handleFunctionCall request: http.Request options: ServeOptions - api: Api + api: Api serverContext: ServerContext context: (serverContext: ServerContext, metadata: Record | undefined) => Promise }): Promise> { @@ -146,26 +151,30 @@ async function handleFunctionCall = functionBody.errors - ? functionReturn - : result.ok(functionReturn) const response = successResponse.encodeWithoutValidation({ success: true, - ...(functionResult.isOk ? { result: functionResult.value as never } : { failure: functionResult.error as never }), + ...(applyResult.isOk ? { result: applyResult.value as never } : { failure: applyResult.error as never }), }) as SuccessResponse return result.ok(response) } catch (error) { diff --git a/packages/direct/src/sdk.ts b/packages/direct/src/sdk.ts index 5d286df5..b7f9819c 100644 --- a/packages/direct/src/sdk.ts +++ b/packages/direct/src/sdk.ts @@ -1,16 +1,25 @@ import { ApiSpecification } from './api' import { Response } from './handler' import { result, model } from '@mondrian-framework/model' -import { functions, sdk, retrieve } from '@mondrian-framework/module' +import { functions, sdk, retrieve, utils } from '@mondrian-framework/module' import { flatMapObject, http } from '@mondrian-framework/utils' -export type Sdk = { - functions: SdkFunctions> - withMetadata: (metadata: Record) => Sdk +export type Sdk< + Fs extends functions.FunctionsInterfaces, + E extends functions.ErrorType, + Exclusions extends { [K in keyof Fs]?: true }, +> = { + functions: SdkFunctions, E> + withMetadata: (metadata: Record) => Sdk } -type SdkFunctions = { - [K in keyof Fs]: SdkFunction +type SdkFunctions = { + [K in keyof Fs]: SdkFunction< + Fs[K]['input'], + Fs[K]['output'], + utils.MergeErrors, + Fs[K]['retrieve'] + > } type SdkFunction< @@ -45,8 +54,9 @@ type SdkFunctionResult< * Builds a new client that will connect to a Mondrian Direct endpoint. */ export function build< - const Fs extends functions.FunctionsInterfaces, - const Exclusions extends { [K in keyof Fs]?: true }, + Fs extends functions.FunctionsInterfaces, + E extends functions.ErrorType, + Exclusions extends { [K in keyof Fs]?: true }, >({ endpoint, api, @@ -57,7 +67,7 @@ export function build< api: ApiSpecification metadata?: Record fetchOptions?: Omit & { headers?: Record } -}): Sdk { +}): Sdk { const funcs = flatMapObject(api.module.functions, (functionName, functionBody) => { if (api.exclusions[functionName]) { return [] @@ -153,7 +163,7 @@ export function build< }) return { - functions: funcs as unknown as Sdk['functions'], + functions: funcs as unknown as Sdk['functions'], withMetadata: (metadata) => build({ endpoint, api, metadata }), } } diff --git a/packages/direct/src/server/fastify.ts b/packages/direct/src/server/fastify.ts index 8a57722e..aa6f11a7 100644 --- a/packages/direct/src/server/fastify.ts +++ b/packages/direct/src/server/fastify.ts @@ -9,19 +9,19 @@ export type ServerContext = { request: FastifyRequest; reply: FastifyReply } /** * Attachs a Direct server to a fastify instace. */ -export function serveWithFastify({ +export function serveWithFastify({ server, api, context, ...args }: { - api: Api + api: Api server: FastifyInstance context: (serverContext: ServerContext, metadata: Record | undefined) => Promise options?: Partial }): void { const options = { ...DEFAULT_SERVE_OPTIONS, ...args.options } - const handler = fromModule({ api, context, options }) + const handler = fromModule({ api, context, options }) const path = api.options?.path ?? '/mondrian' server.post(path, async (request, reply) => { const response = await handler({ diff --git a/packages/direct/tests/module.util.ts b/packages/direct/tests/module.util.ts index 421ac78f..349c90f6 100644 --- a/packages/direct/tests/module.util.ts +++ b/packages/direct/tests/module.util.ts @@ -15,7 +15,7 @@ const ping = functions if (args.input < 0) { throw new Error('Negative ping') } - return args.input + return result.ok(args.input) }, }) @@ -42,7 +42,7 @@ const getUsers = functions }) .implement({ async body() { - return [{ name: 'John' }] + return result.ok([{ name: 'John' }]) }, }) @@ -53,14 +53,27 @@ const omitted = functions }) .implement({ async body() { - return 1 + return result.ok(1) }, }) const m = module.build({ name: 'test', async context() { - return {} + return result.ok({}) + }, + functions: { ping, getUsers, divideBy, omitted }, +}) + +const m2 = module.build({ + name: 'test', + errors: { invalidJwt: model.string() }, + async context(jwt: string) { + if (jwt === 'wrong') { + return result.fail({ invalidJwt: '' }) + } else { + return result.ok({}) + } }, functions: { ping, getUsers, divideBy, omitted }, }) @@ -71,3 +84,10 @@ export const api = buildApi({ }, module: m, }) + +export const api2 = buildApi({ + exclusions: { + omitted: true, + }, + module: m2, +}) diff --git a/packages/direct/tests/sdk.test.ts b/packages/direct/tests/sdk.test.ts index 33e8303c..b3357a71 100644 --- a/packages/direct/tests/sdk.test.ts +++ b/packages/direct/tests/sdk.test.ts @@ -2,7 +2,8 @@ import { DEFAULT_SERVE_OPTIONS } from '../src/api' import { build as buildApi } from '../src/api' import { fromModule } from '../src/handler' import { build } from '../src/sdk' -import { api } from './module.util' +import { api, api2 } from './module.util' +import { result } from '@mondrian-framework/model' import { module } from '@mondrian-framework/module' import http from 'node:http' import { expect, test, describe } from 'vitest' @@ -13,7 +14,7 @@ const handler = fromModule({ if (metadata?.auth !== 'ok') { throw new Error('Unauthorized') } - return {} + return 'ok' }, options: { ...DEFAULT_SERVE_OPTIONS, introspection: true }, }) @@ -59,12 +60,27 @@ describe('direct sdk', () => { }) describe('edge cases', () => { + test('module with failing context build should return a failure', async () => { + const handler = fromModule({ + api: api2, + async context(context, metadata) { + if (metadata?.auth === 'wrong') { + return 'wrong' + } + return 'ok' + }, + options: { ...DEFAULT_SERVE_OPTIONS, introspection: true }, + }) + const client = build({ endpoint: handler, api: api2 }).withMetadata({ auth: 'wrong' }) + const r1 = await client.functions.ping(123) + expect(r1.isFailure && r1.error).toEqual({ invalidJwt: '' }) + }) test('module without functions should throw exception', async () => { const r1 = await fromModule({ api: buildApi({ module: module.build({ async context(input, args) { - return {} + return result.ok({}) }, functions: {}, name: '', diff --git a/packages/example/src/api/graphql.ts b/packages/example/src/api/graphql.ts index 0e3835c7..96a7b76f 100644 --- a/packages/example/src/api/graphql.ts +++ b/packages/example/src/api/graphql.ts @@ -1,5 +1,4 @@ import { module } from '../core' -import { InvalidJwtError } from '../core/errors' import { graphql } from '@mondrian-framework/graphql' import { serveWithFastify as serve } from '@mondrian-framework/graphql-yoga' import { errors } from '@mondrian-framework/module' @@ -27,9 +26,6 @@ export function serveGraphql(server: FastifyInstance) { ip: request.ip, }), errorHandler: async ({ error, logger }) => { - if (error instanceof InvalidJwtError) { - return { message: 'Invalid JWT' } - } if (error instanceof errors.UnauthorizedAccess) { return { message: 'Unauthorized access', options: { extensions: { info: error.error } } } } diff --git a/packages/example/src/api/rest.ts b/packages/example/src/api/rest.ts index 360e8e3d..2bffdcdd 100644 --- a/packages/example/src/api/rest.ts +++ b/packages/example/src/api/rest.ts @@ -1,5 +1,5 @@ import { module } from '../core' -import { InvalidJwtError } from '../core/errors' +import { errors } from '@mondrian-framework/module' import { rest } from '@mondrian-framework/rest' import { serve } from '@mondrian-framework/rest-fastify' import { FastifyInstance } from 'fastify' @@ -22,6 +22,7 @@ const api = rest.build({ loggedUser: { type: 'http', scheme: 'bearer' }, }, errorCodes: { + invalidJwt: 401, invalidLogin: 401, tooManyRequests: 429, }, @@ -37,8 +38,8 @@ export function serveRest(server: FastifyInstance) { ip: request.ip, }), async error({ error, logger }) { - if (error instanceof InvalidJwtError) { - return { status: 400, body: error.message } + if (error instanceof errors.UnauthorizedAccess) { + return { status: 401, body: error.error } } if (error instanceof Error && process.env.ENVIRONMENT !== 'development') { logger.logError(error.message) diff --git a/packages/example/src/core/errors.ts b/packages/example/src/core/errors.ts deleted file mode 100644 index 4d500029..00000000 --- a/packages/example/src/core/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -//Errors thrown by the module - -export class InvalidJwtError extends Error { - public readonly jwt: string - constructor(jwt: string) { - super(`Invalid JWT`) - this.jwt = jwt - } -} diff --git a/packages/example/src/core/impl/post.ts b/packages/example/src/core/impl/post.ts index 871b0543..3cc079ed 100644 --- a/packages/example/src/core/impl/post.ts +++ b/packages/example/src/core/impl/post.ts @@ -26,7 +26,7 @@ export const writePost = module.functions.writePost.implement export const readPosts = module.functions.readPosts.implement({ body: async ({ context, retrieve }) => { const posts = await context.prisma.post.findMany(retrieve) - return posts + return result.ok(posts) }, }) diff --git a/packages/example/src/core/module.ts b/packages/example/src/core/module.ts index 2a0d7497..a5b4c2e5 100644 --- a/packages/example/src/core/module.ts +++ b/packages/example/src/core/module.ts @@ -1,6 +1,6 @@ -import { Context, LoggedUserContext, policies, posts, users } from '.' +import { policies, posts, users } from '.' import { module as moduleInterface } from '../interface' -import { InvalidJwtError } from './errors' +import { result } from '@mondrian-framework/model' import { PrismaClient } from '@prisma/client' import jsonwebtoken from 'jsonwebtoken' @@ -18,7 +18,7 @@ export const module = moduleInterface.implement({ checkOutputType: 'throw', opentelemetry: true, }, - async context({ authorization, ip }: { authorization?: string; ip: string }): Promise { + async context({ authorization, ip }: { authorization?: string; ip: string }) { if (authorization) { const secret = process.env.JWT_SECRET ?? 'secret' const rawJwt = authorization.replace('Bearer ', '') @@ -26,13 +26,13 @@ export const module = moduleInterface.implement({ const jwt = jsonwebtoken.verify(rawJwt, secret, { complete: true }) if (typeof jwt.payload === 'object' && jwt.payload.sub) { const userId = Number(jwt.payload.sub) - return { prisma, ip, userId } + return result.ok({ prisma, ip, userId }) } } catch { - throw new InvalidJwtError(rawJwt) + return result.fail({ invalidJwt: rawJwt }) } } - return { prisma, ip } + return result.ok({ prisma, ip }) }, policies(context) { if (context.userId != null) { diff --git a/packages/example/src/interface/module.ts b/packages/example/src/interface/module.ts index e405a46b..96ffda8e 100644 --- a/packages/example/src/interface/module.ts +++ b/packages/example/src/interface/module.ts @@ -1,9 +1,11 @@ import { posts, users } from '.' +import { model } from '@mondrian-framework/model' import { module as m } from '@mondrian-framework/module' //Instance of of this module interface export const module = m.define({ name: process.env.MODULE_NAME ?? '???', + errors: { invalidJwt: model.string() }, functions: { ...users.actions, ...posts.actions, diff --git a/packages/graphql-yoga/src/fastify.ts b/packages/graphql-yoga/src/fastify.ts index 08e7f705..b882e448 100644 --- a/packages/graphql-yoga/src/fastify.ts +++ b/packages/graphql-yoga/src/fastify.ts @@ -7,14 +7,14 @@ import { createYoga, Plugin, YogaServerOptions } from 'graphql-yoga' export type ServerContext = { request: FastifyRequest; reply: FastifyReply } -export function serveWithFastify({ +export function serveWithFastify({ server, api, context, errorHandler, ...args }: { - api: graphql.Api + api: graphql.Api server: FastifyInstance context: (serverContext: ServerContext, info: GraphQLResolveInfo) => Promise errorHandler?: graphql.ErrorHandler diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index 97fab684..d40f980c 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -7,13 +7,13 @@ import http from 'node:http' export type ServerContext = { req: http.IncomingMessage; res: http.ServerResponse } -export function createServer({ +export function createServer({ api, context, errorHandler, ...args }: { - api: graphql.Api + api: graphql.Api context: (serverContext: ServerContext, info: GraphQLResolveInfo) => Promise errorHandler?: graphql.ErrorHandler options?: Omit, 'schema' | 'context' | 'graphqlEndpoint'> & diff --git a/packages/graphql/src/api.ts b/packages/graphql/src/api.ts index 44ada487..aefb6229 100644 --- a/packages/graphql/src/api.ts +++ b/packages/graphql/src/api.ts @@ -22,17 +22,19 @@ export type ApiSpecification = { * to generate a fully featured graphql schema and serve the module as graphql endpoint. * In order to instantiate this you should use {@link build}. */ -export type Api = ApiSpecification & { +export type Api = ApiSpecification & { /** * Module to serve */ - module: module.Module + module: module.Module } /** * Builds a GraphQL API in order to expose the module. */ -export function build(api: Api): Api { +export function build( + api: Api, +): Api { //assertApiValidity(api) //TODO [Good first issue]: as rest.assertApiValidity return api } @@ -56,7 +58,6 @@ export type ErrorHandler = ( error: unknown logger: logger.MondrianLogger functionName: keyof F - context: unknown tracer: functions.Tracer functionArgs: { retrieve: unknown diff --git a/packages/graphql/src/graphql.ts b/packages/graphql/src/graphql.ts index 9fd3d187..6c2c3ee6 100644 --- a/packages/graphql/src/graphql.ts +++ b/packages/graphql/src/graphql.ts @@ -484,8 +484,13 @@ function customTypeToGraphQLType( /** * Information needed to create a graphql schema from a mondrian module. */ -export type FromModuleInput = { - api: Api +export type FromModuleInput< + Fs extends functions.Functions, + E extends functions.ErrorType, + ServerContext, + ContextInput, +> = { + api: Api context: (context: ServerContext, info: GraphQLResolveInfo) => Promise setHeader: (context: ServerContext, name: string, value: string) => void errorHandler?: ErrorHandler @@ -496,8 +501,8 @@ export type FromModuleInput( - input: FromModuleInput, +export function fromModule( + input: FromModuleInput, ): GraphQLSchema { const { api } = input const moduleFunctions = Object.entries(api.module.functions) @@ -620,12 +625,12 @@ function selectionNodeToRetrieve(info: SelectionNode): Exclude( - module: module.Module, +function makeOperation( + module: module.Module, specification: FunctionSpecifications, functionName: string, functionBody: functions.FunctionImplementation, - fromModuleInput: FromModuleInput, + fromModuleInput: FromModuleInput, internalData: InternalData, ): [string, GraphQLFieldConfig] { const operationName = specification.name ?? functionName @@ -677,21 +682,32 @@ function makeOperation, + const applyResult = await functionBody.apply({ + context: ctxResult.value as Record, retrieve: retrieveValue, input: input as never, tracer: functionBody.tracer, @@ -700,34 +716,32 @@ function makeOperation> - if (applyResult.isOk) { + if (applyResult.isOk) { + if (functionBody.errors) { //wrap in an object if the output was wrapped by getFunctionOutputTypeWithErrors function const objectValue = isOutputTypeWrapped ? { value: applyResult.value } : applyResult.value outputValue = partialOutputType.encodeWithoutValidation(objectValue as never) } else { - const errorCode = Object.keys(applyResult.error)[0] - const errorValue = applyResult.error[errorCode] - const mappedError = { - '[GraphQL generation]: isError': true, - errorCode, - errorValue, - errors: applyResult.error, - } - outputValue = partialOutputType.encodeWithoutValidation(mappedError as never) + outputValue = partialOutputType.encodeWithoutValidation(applyResult.value as never) + span?.setStatus({ code: SpanStatusCode.OK }) + span?.end() } - endSpanWithResult(applyResult, span) } else { - outputValue = partialOutputType.encodeWithoutValidation(applyOutput as never) - span?.setStatus({ code: SpanStatusCode.OK }) - span?.end() + const errorCode = Object.keys(applyResult.error)[0] + const errorValue = applyResult.error[errorCode] + const mappedError = { + '[GraphQL generation]: isError': true, + errorCode, + errorValue, + errors: applyResult.error, + } + outputValue = partialOutputType.encodeWithoutValidation(mappedError as never) } + endSpanWithResult(applyResult, span) return outputValue } catch (error) { if (fromModuleInput.errorHandler) { const result = await fromModuleInput.errorHandler({ - context: moduleContext, error, functionArgs: { retrieve: retrieveValue, input }, functionName: operationName, diff --git a/packages/graphql/tests/graphql.test.ts b/packages/graphql/tests/graphql.test.ts index 54db9679..4e5f7917 100644 --- a/packages/graphql/tests/graphql.test.ts +++ b/packages/graphql/tests/graphql.test.ts @@ -58,7 +58,7 @@ const pongUser = functions if (typeof input === 'string') { throw new Error(input) } - return input + return result.ok(input) }, }) const Metadata = () => @@ -77,7 +77,7 @@ const pongMetadata = functions }) .implement({ body: async ({ input }) => { - return input + return result.ok(input) }, }) @@ -88,7 +88,7 @@ const addOne = functions }) .implement({ body: async ({ input }) => { - return input + 1 + return result.ok(input + 1) }, }) @@ -96,7 +96,7 @@ const m = module.build({ name: 'test', options: { maxSelectionDepth: 2 }, functions: { addOne, register, pongUser, pongMetadata }, - context: async () => ({}), + context: async () => result.ok({}), }) type ServerContext = { req: http.IncomingMessage; res: http.ServerResponse } diff --git a/packages/middleware/rate-limiter/tests/module.test.ts b/packages/middleware/rate-limiter/tests/module.test.ts index 6a8c3b47..85a5d596 100644 --- a/packages/middleware/rate-limiter/tests/module.test.ts +++ b/packages/middleware/rate-limiter/tests/module.test.ts @@ -58,7 +58,7 @@ test('Rate limiter middleware', async () => { name: 'test', functions: { login }, context: async ({ ip }: { ip: string }) => { - return { ip } + return result.ok({ ip }) }, }) diff --git a/packages/module/src/function.ts b/packages/module/src/function.ts index bd7f9455..d44629fd 100644 --- a/packages/module/src/function.ts +++ b/packages/module/src/function.ts @@ -152,14 +152,16 @@ export type FunctionResult = [C] extends [{ select: true }] ? - [E] extends [model.Types] ? result.Result>, InferErrorType> - : [E] extends [undefined] ? model.Infer> - : any - : [E] extends [model.Types] ? result.Result, InferErrorType> - : [E] extends [undefined] ? model.Infer - : any + [Exclude] extends [infer E1 extends model.Types] ? result.Result>, InferErrorType> + : [E] extends [undefined] ? result.Result>, never> + : result.Result> + : [Exclude] extends [infer E1 extends model.Types] ? result.Result, InferErrorType> + : [E] extends [undefined] ? result.Result, never> + : result.Result> -type InferErrorType = AtLeastOnePropertyOf<{ [K in keyof Ts]: model.Infer }> +export type InferErrorType = 0 extends 1 & Ts + ? never + : AtLeastOnePropertyOf<{ [K in keyof Ts]: model.Infer }> type AtLeastOnePropertyOf = { [K in keyof T]: { [L in K]: T[L] } & { [L in Exclude]?: T[L] } }[keyof T] @@ -193,13 +195,13 @@ export type Middleware< * Apply function of this middleware. * @param args Argument of the functin invokation. Can be transformed with `next` callback. * @param next Continuation callback of the middleware. - * @param thisFunction Reference to the underlying {@link FunctionImplementation}. + * @param fn Reference to the underlying {@link FunctionImplementation}. * @returns a value that respect function's output type and the given projection. */ apply: ( args: FunctionArguments, next: (args: FunctionArguments) => FunctionResult, - thisFunction: FunctionImplementation, + fn: FunctionImplementation, ) => FunctionResult } @@ -265,13 +267,13 @@ export function define< * }) * ``` */ - implement: = {}>( + implement: = {}>( implementation: Pick, 'body' | 'middlewares'>, ) => FunctionImplementation } { return { ...func, - implement = {}>( + implement = {}>( implementation: Pick, 'body' | 'middlewares'>, ) { if (func.errors) { diff --git a/packages/module/src/middleware.ts b/packages/module/src/middleware.ts index 0fbc93b9..678c880c 100644 --- a/packages/module/src/middleware.ts +++ b/packages/module/src/middleware.ts @@ -34,47 +34,38 @@ export function checkOutputType( return { name: 'Check output type', apply: async (args, next, thisFunction) => { - const nextRes: result.Result | unknown = await next(args) - if (thisFunction.errors && (nextRes as result.Result).isFailure) { - //Checks the error type - const errorDecodeResult = utils.decodeFunctionFailure( - (nextRes as result.Failure).error, - thisFunction.errors, - { - errorReportingStrategy: 'allErrors', - fieldStrictness: 'expectExactFields', - }, - ) - if (errorDecodeResult.isFailure) { - handleFailure({ onFailure, functionName, logger: args.logger, result: errorDecodeResult }) + const originalResult = await next(args) + if (originalResult.isFailure) { + if (!thisFunction.errors) { + throw new Error( + `Unexpected failure on function ${functionName}. It doesn't declare errors nor the module declares errors.`, + ) } - return errorDecodeResult.isOk ? result.fail(errorDecodeResult.value) : nextRes + const mappedError = utils.decodeFunctionFailure(originalResult.error, thisFunction.errors, { + errorReportingStrategy: 'allErrors', + fieldStrictness: 'expectExactFields', + }) + if (mappedError.isFailure) { + handleFailure({ onFailure, functionName, logger: args.logger, result: mappedError }) + return originalResult + } + return result.fail(mappedError.value as {}) } - //Unwrap the value - let outputValue - if (thisFunction.errors) { - outputValue = (nextRes as result.Ok).value - } else { - outputValue = nextRes - } const retrieveType = retrieve.fromType(thisFunction.output, thisFunction.retrieve) const defaultRetrieve = retrieveType.isOk ? { select: {} } : {} const typeToRespect = retrieve.selectedType(thisFunction.output, args.retrieve ?? defaultRetrieve) - const valueDecodeResult = model.concretise(typeToRespect).decode(outputValue as never, { + const mappedResult = model.concretise(typeToRespect).decode(originalResult.value as never, { errorReportingStrategy: 'allErrors', fieldStrictness: 'allowAdditionalFields', }) - if (valueDecodeResult.isFailure) { - handleFailure({ onFailure, functionName, logger: args.logger, result: valueDecodeResult }) - return outputValue - } else if (thisFunction.errors) { - return result.ok(valueDecodeResult.value) - } else { - return valueDecodeResult.value + if (mappedResult.isFailure) { + handleFailure({ onFailure, functionName, logger: args.logger, result: mappedResult }) + return originalResult } + return mappedResult as result.Ok }, } } diff --git a/packages/module/src/module.ts b/packages/module/src/module.ts index d99f4d89..fd63e1c8 100644 --- a/packages/module/src/module.ts +++ b/packages/module/src/module.ts @@ -3,8 +3,8 @@ import { ErrorType, OutputRetrieveCapabilities, Tracer } from './function' import { BaseFunction } from './function/base' import { OpentelemetryFunction } from './function/opentelemetry' import * as middleware from './middleware' -import { allUniqueTypes } from './utils' -import { model } from '@mondrian-framework/model' +import { allUniqueTypes, mergeErrors } from './utils' +import { model, result } from '@mondrian-framework/model' import { UnionToIntersection } from '@mondrian-framework/utils' import opentelemetry, { ValueType } from '@opentelemetry/api' @@ -12,20 +12,28 @@ import opentelemetry, { ValueType } from '@opentelemetry/api' * The Mondrian module interface. * Contains only the function signatures, module name and version. */ -export interface ModuleInterface { +export interface ModuleInterface< + Fs extends functions.FunctionsInterfaces = functions.FunctionsInterfaces, + E extends ErrorType = ErrorType, +> { name: string description?: string functions: Fs + errors?: E } /** * The Mondrian module type. * Contains all the module functions with also the implementation and how to build the context. */ -export interface Module - extends ModuleInterface { +export interface Module< + Fs extends functions.Functions = functions.Functions, + E extends ErrorType = ErrorType, + ContextInput = unknown, +> extends ModuleInterface { name: string functions: Fs + errors?: E policies?: (context: ContextType) => security.Policies context: ( input: ContextInput, @@ -36,10 +44,15 @@ export interface Module Promise> + ) => Promise> options?: ModuleOptions } +//prettier-ignore +type ContextResultType + = [E] extends [model.Types] ? result.Result, functions.InferErrorType> + : result.Result, never> + /** * Mondrian module options. */ @@ -123,10 +136,18 @@ function assertUniqueNames(functions: functions.FunctionsInterfaces) { * }) * ``` */ -export function build( - module: Module, -): Module { +export function build( + module: Module, +): Module { assertUniqueNames(module.functions) + //TODO: this logic is the same as function implement, refactor + if (module.errors) { + const undefinedError = Object.entries(module.errors).find(([_, errorType]) => model.isOptional(errorType)) + if (undefinedError) { + throw new Error(`Module errors cannot be optional. Error "${undefinedError[0]}" is optional`) + } + } + const maxProjectionDepthMiddleware = module.options?.maxSelectionDepth != null ? [middleware.checkMaxSelectionDepth(module.options.maxSelectionDepth)] @@ -142,6 +163,7 @@ export function build( const func: functions.FunctionImplementation = { ...functionBody, + errors: mergeErrors(functionBody.errors, module.errors, functionName), middlewares: [ ...maxProjectionDepthMiddleware, ...(functionBody.middlewares ?? []), @@ -175,8 +197,8 @@ export function build( * @param module a map of {@link FunctionInterface}, module name and module version. * @returns the module interface */ -export function define( - module: ModuleInterface, +export function define( + module: ModuleInterface, ): ModuleInterface & { implement: < FsI extends { @@ -184,8 +206,8 @@ export function define( }, ContextInput, >( - module: Pick, 'functions' | 'context' | 'policies' | 'options'>, - ) => Module + module: Pick, 'functions' | 'context' | 'policies' | 'options'>, + ) => Module } { assertUniqueNames(module.functions) return { ...module, implement: (moduleImpl) => build({ ...module, ...moduleImpl }) } diff --git a/packages/module/src/sdk.ts b/packages/module/src/sdk.ts index 6f6fd15b..39c3d83a 100644 --- a/packages/module/src/sdk.ts +++ b/packages/module/src/sdk.ts @@ -1,15 +1,16 @@ import { functions, module, retrieve, utils } from '.' import { logger as mondrianLogger } from '.' import { ErrorType } from './function' +import { MergeErrors } from './utils' import { result, model } from '@mondrian-framework/model' -export type Sdk = { - functions: SdkFunctions - withMetadata: (metadata: Metadata) => Sdk +export type Sdk = { + functions: SdkFunctions + withMetadata: (metadata: Metadata) => Sdk } -type SdkFunctions = { - [K in keyof F]: SdkFunction +type SdkFunctions = { + [K in keyof F]: SdkFunction, F[K]['retrieve'], Metadata> } type SdkFunction< @@ -164,13 +165,13 @@ class SdkBuilder { this.metadata = metadata } - public build({ + public build({ module, context, }: { - module: module.Module + module: module.Module context: (args: { metadata?: Metadata }) => Promise - }): Sdk { + }): Sdk { const presetLogger = mondrianLogger.build({ moduleName: module.name, server: 'LOCAL' }) const fs = Object.fromEntries( Object.entries(module.functions).map(([functionName, functionBody]) => { @@ -184,20 +185,30 @@ class SdkBuilder { const thisLogger = presetLogger.updateContext({ operationName: functionName }) try { const contextInput = await context({ metadata: options?.metadata ?? this.metadata }) - const ctx = await module.context(contextInput, { + const ctxResult = await module.context(contextInput, { input, retrieve: options?.retrieve, tracer: functionBody.tracer, logger: thisLogger, functionName, }) + if (ctxResult.isFailure) { + return ctxResult + } const result = await functionBody.apply({ input: input as never, retrieve: options?.retrieve ?? {}, - context: ctx, + context: ctxResult.value, tracer: functionBody.tracer, logger: thisLogger, }) + if (!functionBody.errors) { + if (result.isOk) { + return result.value + } else { + throw new Error(`Unexpected failure result for function ${functionName}`) + } + } return result } catch (error) { throw error @@ -207,7 +218,7 @@ class SdkBuilder { }), ) return { - functions: fs as unknown as SdkFunctions, + functions: fs as unknown as SdkFunctions, withMetadata: (metadata) => withMetadata(metadata).build({ module, context }), } } @@ -217,9 +228,9 @@ export function withMetadata(metadata?: Metadata): SdkBuilder(args: { - module: module.Module +export function build(args: { + module: module.Module context: (args: { metadata?: unknown }) => Promise -}): Sdk { +}): Sdk { return withMetadata().build(args) } diff --git a/packages/module/src/utils.ts b/packages/module/src/utils.ts index 10fed608..18edb49d 100644 --- a/packages/module/src/utils.ts +++ b/packages/module/src/utils.ts @@ -1,4 +1,5 @@ import { functions } from '.' +import { ErrorType } from './function' import { decoding, model, result, validation } from '@mondrian-framework/model' import { mapObject } from '@mondrian-framework/utils' @@ -63,3 +64,35 @@ export function decodeFunctionFailure( } return errorDecodeResult } + +//prettier-ignore +export type MergeErrors + = [E1] extends [undefined] ? E2 + : [E2] extends [undefined] ? E1 + : { [K in (keyof E1 | keyof E2)]: K extends keyof E1 ? E1[K] : K extends keyof E2 ? E2[K] : never } extends (infer E extends ErrorType) ? E : never + +export function mergeErrors( + l: ErrorType | undefined, + r: ErrorType | undefined, + functionName: string, +): ErrorType | undefined { + if (!l) { + return r + } + if (!r) { + return l + } + const errors: Record = {} + for (const key of [...Object.keys(l), ...Object.keys(r)]) { + if (!l[key]) { + errors[key] = r[key] + } else if (!r[key]) { + errors[key] = l[key] + } else if (model.areEqual(l[key], r[key])) { + errors[key] = l[key] + } else { + throw new Error(`Duplicate error definition "${key}". Both on module and on function "${functionName ?? ''}"`) + } + } + return errors +} diff --git a/packages/module/tests/module.test.ts b/packages/module/tests/module.test.ts index 4c6edb69..da7e2f92 100644 --- a/packages/module/tests/module.test.ts +++ b/packages/module/tests/module.test.ts @@ -133,17 +133,18 @@ test('Real example', async () => { name: 'test', options: { maxSelectionDepth: 2 }, functions: { login, register, completeProfile }, + errors: { unathorized: model.string() }, context: async ({ ip, authorization }: { ip: string; authorization: string | undefined }) => { if (authorization != null) { //dummy auth const user = db.findUser({ email: authorization }) if (user) { - return { from: ip, authenticatedUser: { email: user.email }, db } + return result.ok({ from: ip, authenticatedUser: { email: user.email }, db }) } else { - throw `Invalid authorization` + return result.fail({ unathorized: 'Invalid authorization' }) } } - return { from: ip, db } + return result.ok({ from: ip, db }) }, }) @@ -179,13 +180,12 @@ test('Real example', async () => { const completeProfileResult = await client.functions.completeProfile({ firstname: 'Pieter', lastname: 'Mondriaan' }) expect(completeProfileResult.isFailure && completeProfileResult.error).toEqual({ unauthorized: 'unauthorized' }) - await expect( - async () => - await client.functions.completeProfile( - { firstname: 'Pieter', lastname: 'Mondriaan' }, - { metadata: { authorization: 'wrong' } }, - ), - ).rejects.toThrow() + const r1 = await client.functions.completeProfile( + { firstname: 'Pieter', lastname: 'Mondriaan' }, + { metadata: { authorization: 'wrong' } }, + ) + expect(r1.isFailure && r1.error).toEqual({ unathorized: 'Invalid authorization' }) + if (loginResult.isOk && loginResult.value) { const authClient = client.withMetadata({ authorization: loginResult.value.jwt }) const myUser = await authClient.functions.completeProfile({ firstname: 'Pieter', lastname: 'Mondriaan' }, {}) @@ -217,7 +217,7 @@ describe('Unique type name', () => { module.build({ name: 'test', functions: { f }, - context: async () => ({}), + context: async () => result.ok({}), }), ).toThrowError(`Duplicated type name "Input"`) }) @@ -235,9 +235,9 @@ describe('Default middlewares', () => { .implement({ body: async ({ input }) => { if (input?.value === 'wrong') { - return {} //selection not respected sometimes! + return result.ok({}) //selection not respected sometimes! } - return input + return result.ok(input) }, }) const m = module.build({ @@ -247,7 +247,7 @@ describe('Default middlewares', () => { checkOutputType: 'throw', maxSelectionDepth: 2, }, - context: async () => ({}), + context: async () => result.ok({}), policies: () => security.on(type).allows({ selection: true }), }) @@ -292,14 +292,14 @@ test('Return types', async () => { }) .implement({ body: async () => { - return { email: 'email@domain.com', metadata: { tags: [] }, friends: [] } + return result.ok({ email: 'email@domain.com', metadata: { tags: [] }, friends: [] }) }, }) const m = module.build({ name: 'test', functions: { login }, - context: async () => ({}), + context: async () => result.ok({}), }) const client = sdk.withMetadata<{ ip?: string; authorization?: string }>().build({ @@ -382,12 +382,22 @@ test('Errors return', async () => { } }, }) + const unfailableFunction = functions + .define({ + input: model.string(), + output: model.string(), + }) + .implement({ + body: async ({ input }) => { + return result.fail(1 as never) + }, + }) const m = module.build({ name: 'test', - functions: { errorTest }, + functions: { errorTest, unfailableFunction }, options: { checkOutputType: 'throw' }, - context: async () => ({}), + context: async () => result.ok({}), }) const client = sdk.withMetadata<{ ip?: string; authorization?: string }>().build({ @@ -412,9 +422,13 @@ test('Errors return', async () => { expect(() => client.functions.errorTest('6')).rejects.toThrowError( 'Invalid output on function errorTest. Errors: (1) {"expected":"undefined","got":1,"path":"$.wrong"}', ) + + expect(() => client.functions.unfailableFunction('1')).rejects.toThrowError( + "Unexpected failure on function unfailableFunction. It doesn't declare errors nor the module declares errors.", + ) }) -test('Undefiend function error tyoe', async () => { +test('Undefiend function error type', async () => { expect(() => functions .define({ diff --git a/packages/module/tests/opentelemetry.test.ts b/packages/module/tests/opentelemetry.test.ts index 69049c48..38bf3b16 100644 --- a/packages/module/tests/opentelemetry.test.ts +++ b/packages/module/tests/opentelemetry.test.ts @@ -57,7 +57,7 @@ describe('Opentelemetry', () => { checkOutputType: 'throw', opentelemetry: true, }, - context: async () => ({}), + context: async () => result.ok({}), }) const client = sdk.build({ diff --git a/packages/rest-fastify/src/methods.ts b/packages/rest-fastify/src/methods.ts index bb0be346..9f558cd5 100644 --- a/packages/rest-fastify/src/methods.ts +++ b/packages/rest-fastify/src/methods.ts @@ -4,7 +4,7 @@ import { rest, utils } from '@mondrian-framework/rest' import { http, isArray } from '@mondrian-framework/utils' import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' -export function attachRestMethods({ +export function attachRestMethods({ server, api, context, @@ -12,7 +12,7 @@ export function attachRestMethods( error, }: { server: FastifyInstance - api: rest.Api + api: rest.Api context: (serverContext: ServerContext) => Promise pathPrefix: string error?: rest.ErrorHandler @@ -26,14 +26,14 @@ export function attachRestMethods( const paths = utils .getPathsFromSpecification({ functionName, specification, prefix: pathPrefix, maxVersion: api.version }) .map((p) => p.replace(/{(.*?)}/g, ':$1')) - const restHandler = rest.handler.fromFunction({ + const restHandler = rest.handler.fromFunction({ module: api.module, context, specification, functionName, functionBody, error, - api, + api: api as any, }) const fastifyHandler = async (request: FastifyRequest, reply: FastifyReply) => { const result = await restHandler({ diff --git a/packages/rest-fastify/src/server.ts b/packages/rest-fastify/src/server.ts index e88fc28e..fb388471 100644 --- a/packages/rest-fastify/src/server.ts +++ b/packages/rest-fastify/src/server.ts @@ -11,14 +11,14 @@ import { getAbsoluteFSPath } from 'swagger-ui-dist' export type ServerContext = { request: FastifyRequest; reply: FastifyReply } -export function serve({ +export function serve({ api, server, context, error, ...args }: { - api: rest.Api + api: rest.Api server: FastifyInstance context: (serverContext: ServerContext) => Promise error?: rest.ErrorHandler diff --git a/packages/rest/src/api.ts b/packages/rest/src/api.ts index c6390c8c..43f4b187 100644 --- a/packages/rest/src/api.ts +++ b/packages/rest/src/api.ts @@ -1,5 +1,5 @@ import { assertApiValidity } from './utils' -import { functions, logger, module, retrieve } from '@mondrian-framework/module' +import { functions, logger, module, retrieve, utils } from '@mondrian-framework/module' import { KeysOfUnion, http } from '@mondrian-framework/utils' import { OpenAPIV3_1 } from 'openapi-types' @@ -8,7 +8,7 @@ import { OpenAPIV3_1 } from 'openapi-types' * This contains all information needed to generate an openapi specification document. * It does not contains the implementation. In order to instantiate this you should use {@link define}. */ -export type ApiSpecification = { +export type ApiSpecification = { /** * The current api version. Must be an integer greater than or quelas to 1. */ @@ -39,9 +39,9 @@ export type ApiSpecification = { errorCodes?: { [K in KeysOfUnion< { - [K2 in keyof Fs]: Exclude extends never + [K2 in keyof Fs]: Exclude, E>, undefined> extends never ? never - : Exclude + : Exclude, E>, undefined> }[keyof Fs] >]?: number } @@ -56,19 +56,22 @@ export type ApiSpecification = { * this contains also the function implementations. With an instance of {@link Api} it is possible * to serve the module with a rest server. In order to instantiate this you should use {@link build}. */ -export type Api = ApiSpecification & { +export type Api = ApiSpecification< + Fs, + E +> & { /** * Module to serve */ - module: module.Module + module: module.Module } /** * Builds a REST API in order to expose the module. */ -export function build( - api: Api, -): Api { +export function build( + api: Api, +): Api { assertApiValidity(api) return api } @@ -76,9 +79,9 @@ export function build( /** * Defines the REST API with just the module interface. */ -export function define( - api: ApiSpecification, -): ApiSpecification { +export function define( + api: ApiSpecification, +): ApiSpecification { assertApiValidity(api) return api } @@ -88,7 +91,6 @@ export type ErrorHandler = ( error: unknown logger: logger.MondrianLogger functionName: keyof Fs - context: unknown tracer: functions.Tracer functionArgs: { retrieve?: retrieve.GenericRetrieve diff --git a/packages/rest/src/handler.ts b/packages/rest/src/handler.ts index 171053b3..00094c56 100644 --- a/packages/rest/src/handler.ts +++ b/packages/rest/src/handler.ts @@ -7,7 +7,12 @@ import { http } from '@mondrian-framework/utils' import { SpanKind, SpanStatusCode, Span } from '@opentelemetry/api' import { SemanticAttributes } from '@opentelemetry/semantic-conventions' -export function fromFunction({ +export function fromFunction< + Fs extends functions.Functions, + E extends functions.ErrorType, + ServerContext, + ContextInput, +>({ functionName, module, specification, @@ -17,12 +22,12 @@ export function fromFunction + module: module.Module functionBody: functions.FunctionImplementation specification: FunctionSpecifications context: (serverContext: ServerContext) => Promise error?: ErrorHandler - api: Pick, 'errorCodes'> + api: Pick, 'errorCodes'> }): http.Handler { const getInputFromRequest = specification.openapi ? specification.openapi.input @@ -67,41 +72,45 @@ export function fromFunction) { + const codes = { ...api.errorCodes, ...specification.errorCodes } as Record + const key = Object.keys(error)[0] + const status = key ? codes[key] ?? 400 : 400 + const response: http.Response = { + status, + body: error, + headers: { 'Content-Type': 'application/json' }, + } + endSpanWithResponse({ span, response }) + return response + } + try { //context building const contextInput = await context(serverContext) - moduleContext = await module.context(contextInput, { + const ctxResult = await module.context(contextInput, { retrieve: retrieveValue, input, tracer: functionBody.tracer, logger: thisLogger, functionName, }) - + if (ctxResult.isFailure) { + return handleFailure(ctxResult.error) + } // Function call - const applyOutput = await functionBody.apply({ + const applyResult = await functionBody.apply({ retrieve: retrieveValue ?? {}, input: input as never, - context: moduleContext as Record, + context: ctxResult.value as Record, tracer: functionBody.tracer, logger: thisLogger, }) //Output processing - if (functionBody.errors && applyOutput.isFailure) { - const codes = { ...api.errorCodes, ...specification.errorCodes } as Record - const key = Object.keys(applyOutput.error)[0] - const status = key ? codes[key] ?? 400 : 400 - const response: http.Response = { - status, - body: applyOutput.error, - headers: { 'Content-Type': 'application/json' }, - } - endSpanWithResponse({ span, response }) - return response + if (applyResult.isFailure) { + return handleFailure(applyResult.error) } else { - const value = functionBody.errors ? applyOutput.value : applyOutput //unwrap output - const encoded = partialOutputType.encodeWithoutValidation(value as never) + const encoded = partialOutputType.encodeWithoutValidation(applyResult.value) const response: http.Response = { status: 200, body: encoded, @@ -121,7 +130,6 @@ export function fromFunction({ +export function fromModule({ api, version, }: { - api: ApiSpecification + api: ApiSpecification version: number }): OpenAPIV3_1.Document { const paths: OpenAPIV3_1.PathsObject = {} @@ -317,12 +317,12 @@ function generatePathParameters({ return result } -function openapiComponents({ +function openapiComponents({ version, api, }: { version: number - api: ApiSpecification + api: ApiSpecification }): { components: OpenAPIV3_1.ComponentsObject internalData: InternalData diff --git a/packages/rest/src/utils.ts b/packages/rest/src/utils.ts index 52b4d157..9bfbcf04 100644 --- a/packages/rest/src/utils.ts +++ b/packages/rest/src/utils.ts @@ -76,7 +76,7 @@ export function getPathsFromSpecification({ * - paths syntax * @param api the api configuration */ -export function assertApiValidity(api: ApiSpecification) { +export function assertApiValidity(api: ApiSpecification) { if (api.version < 1 || !Number.isInteger(api.version) || api.version > 100) { throw new Error(`Invalid api version. Must be between 1 and 100 and be an integer. Got ${api.version}`) } diff --git a/packages/rest/tests/handler.test.ts b/packages/rest/tests/handler.test.ts index 1cb13e3a..8c17be14 100644 --- a/packages/rest/tests/handler.test.ts +++ b/packages/rest/tests/handler.test.ts @@ -16,7 +16,7 @@ describe('rest handler', () => { }) .implement({ async body() { - return 1 + return result.ok(1) }, }) const f1 = functions @@ -26,7 +26,7 @@ describe('rest handler', () => { }) .implement({ async body({ input }) { - return Number(input) + return result.ok(Number(input)) }, }) const f2 = functions @@ -36,7 +36,7 @@ describe('rest handler', () => { }) .implement({ async body({ input: { a, b } }) { - return a * b + return result.ok(a * b) }, }) const f3 = functions @@ -63,7 +63,7 @@ describe('rest handler', () => { if (ping !== 'ping') { throw new Error('Not a ping!') } - return 'pong' as const + return result.ok('pong') }, }) const user = () => model.entity({ username: model.string(), live: model.boolean(), friend: model.optional(user) }) @@ -76,9 +76,9 @@ describe('rest handler', () => { .implement({ async body({ retrieve }) { if (retrieve.select?.friend) { - return { live: true, username: 'name', friend: { live: true, username: 'name2' } } + return result.ok({ live: true, username: 'name', friend: { live: true, username: 'name2' } }) } - return { live: true, username: 'name' } + return result.ok({ live: true, username: 'name' }) }, }) const f6 = functions @@ -88,12 +88,12 @@ describe('rest handler', () => { }) .implement({ async body({ input: { a, b } }) { - return a * b.a * b.b + return result.ok(a * b.a * b.b) }, }) const fs = { f0, f1, f2, f3, f4, f5, f6 } as const const m = module.build({ - context: async () => ({}), + context: async () => result.ok({}), functions: fs, name: 'example', })