UNPKG

@hasura/ndc-sdk-typescript

Version:

This SDK is mostly analogous to the Rust SDK, except where necessary.

396 lines (353 loc) 10.3 kB
import Fastify, { FastifyRequest } from "fastify"; import compress from "@fastify/compress"; import opentelemetry, { SpanStatusCode, } from "@opentelemetry/api"; import { Connector } from "./connector"; import { ConnectorError } from "./error"; import { configureFastifyLogging } from "./logging"; import { CapabilitiesResponseSchema, SchemaResponseSchema, QueryRequestSchema, QueryResponseSchema, ExplainResponseSchema, MutationRequestSchema, MutationResponseSchema, ErrorResponseSchema, CapabilitiesResponse, SchemaResponse, MutationResponse, MutationRequest, QueryRequest, VERSION, VERSION_HEADER_NAME, ErrorResponse, } from "./schema"; import { Options as AjvOptions } from "ajv"; import { withActiveSpan } from "./instrumentation"; import { Registry, collectDefaultMetrics } from "prom-client"; import semver from "semver"; // Create custom Ajv options to handle Rust's uint types which are formats used in the JSON schemas, so this converts that to a number const customAjvOptions: AjvOptions = { allErrors: true, removeAdditional: true, formats: { uint: { validate: (data: any) => { return ( typeof data === "number" && data >= 0 && data <= 4294967295 && Number.isInteger(data) ); }, type: "number", }, uint32: { validate: (data: any) => { return ( typeof data === "number" && data >= 0 && data <= 4294967295 && Number.isInteger(data) ); }, type: "number", }, }, }; const errorResponses = { 400: ErrorResponseSchema, 403: ErrorResponseSchema, 409: ErrorResponseSchema, 422: ErrorResponseSchema, 500: ErrorResponseSchema, 501: ErrorResponseSchema, 502: ErrorResponseSchema, }; export interface ServerOptions { configuration: string; host: string; port: number; serviceTokenSecret: string | undefined; logLevel: string; prettyPrintLogs: string; } const tracer = opentelemetry.trace.getTracer("ndc-sdk-typescript.server"); export async function startServer<Configuration, State>( connector: Connector<Configuration, State>, options: ServerOptions ) { const configuration = await connector.parseConfiguration( options.configuration ); const metrics = new Registry(); collectDefaultMetrics({ register: metrics }); const state = await connector.tryInitState(configuration, metrics); const server = Fastify({ logger: configureFastifyLogging(options), bodyLimit: 1048576 * 30, // 30mb body limit ajv: { customOptions: customAjvOptions, }, }); // Register compression plugin await server.register(compress, { global: true, // TODO add zstd when we upgrade to Node.js 22.15+/23.8+ encodings: ['gzip', 'deflate'], }); // temporary: use JSON.stringify instead of https://github.com/fastify/fast-json-stringify // todo: remove this once issue is addressed https://github.com/fastify/fastify/issues/5073 server.setSerializerCompiler( ({ schema, method, url, httpStatus, contentType }) => { return (data) => JSON.stringify(data); } ); // Authorization handler server.addHook("preHandler", async (request, reply) => { // Don't apply authorization to the healthcheck endpoint if (request.routeOptions.method === "GET" && request.routeOptions.url === "/health") { return; } const expectedAuthHeader = options.serviceTokenSecret === undefined ? undefined : `Bearer ${options.serviceTokenSecret}`; if (request.headers.authorization === expectedAuthHeader) { return; } else { reply.code(401).send(<ErrorResponse>{ message: "Internal Error", details: { cause: "Bearer token does not match.", }, }); return reply; } }); // NDC Version header handler const lowercaseVersionHeaderName = VERSION_HEADER_NAME.toLowerCase(); const connectorSemVer = new semver.SemVer(VERSION); server.addHook("preHandler", async (request, reply) => { const versionHeader = request.headers[lowercaseVersionHeaderName]; if (versionHeader === undefined) { return; } if (Array.isArray(versionHeader)) { reply.code(400).send(<ErrorResponse>{ message: `Multiple ${VERSION_HEADER_NAME} headers received. Only one is supported.`, }) return reply; } let wantedSemVer: semver.SemVer; try { wantedSemVer = new semver.SemVer(versionHeader); } catch (e) { reply.code(400).send(<ErrorResponse>{ message: `Invalid semver in ${VERSION_HEADER_NAME}s header`, details: e instanceof Error ? { error: e.message } : {} }) return reply; } const wantedSemVerRange = new semver.Range(`^${wantedSemVer.toString()}`); if (!semver.satisfies(connectorSemVer, wantedSemVerRange)) { reply.code(400).send(<ErrorResponse>{ message: `The connector does not support the requested NDC version`, details: { connectorVersion: connectorSemVer.toString(), requestedVersionRange: wantedSemVerRange.toString(), } }) return reply; } }); server.get( "/capabilities", { schema: { response: { 200: CapabilitiesResponseSchema, ...errorResponses, }, }, }, (_request: FastifyRequest): CapabilitiesResponse => { return withActiveSpan( tracer, "getCapabilities", () => ({ version: VERSION, capabilities: connector.getCapabilities(configuration), }) ); } ); server.get("/health", async (_request): Promise<undefined> => { return connector.getHealthReadiness ? await connector.getHealthReadiness(configuration, state) : undefined; }); server.get("/metrics", (_request) => { connector.fetchMetrics(configuration, state); return metrics.metrics(); }); server.get( "/schema", { schema: { response: { 200: SchemaResponseSchema, ...errorResponses, }, }, }, (_request): Promise<SchemaResponse> => { return withActiveSpan( tracer, "getSchema", () => connector.getSchema(configuration) ); } ); server.post( "/query", { schema: { body: QueryRequestSchema, response: { 200: QueryResponseSchema, ...errorResponses, }, }, }, async ( request: FastifyRequest<{ Body: QueryRequest; }> ) => { request.log.debug({ requestHeaders: request.headers, requestBody: request.body }, "Query Request"); const queryResponse = await withActiveSpan( tracer, "handleQuery", () => connector.query(configuration, state, request.body) ); request.log.debug({ responseBody: queryResponse }, "Query Response"); return queryResponse; } ); server.post( "/query/explain", { schema: { body: QueryRequestSchema, response: { 200: ExplainResponseSchema, ...errorResponses, }, }, }, async (request: FastifyRequest<{ Body: QueryRequest }>) => { request.log.debug({ requestHeaders: request.headers, requestBody: request.body }, "Explain Request"); const explainResponse = await withActiveSpan( tracer, "handleQueryExplain", () => connector.queryExplain(configuration, state, request.body) ); request.log.debug( { responseBody: explainResponse }, "Query Explain Response" ); return explainResponse; } ); server.post( "/mutation", { schema: { body: MutationRequestSchema, response: { 200: MutationResponseSchema, ...errorResponses, }, }, }, async ( request: FastifyRequest<{ Body: MutationRequest; }> ): Promise<MutationResponse> => { request.log.debug({ requestHeaders: request.headers, requestBody: request.body }, "Mutation Request"); const mutationResponse = await withActiveSpan( tracer, "handleMutation", () => connector.mutation(configuration, state, request.body) ); request.log.debug( { responseBody: mutationResponse }, "Mutation Response" ); return mutationResponse; } ); server.post( "/mutation/explain", { schema: { body: MutationRequestSchema, response: { 200: ExplainResponseSchema, ...errorResponses, }, }, }, async (request: FastifyRequest<{ Body: MutationRequest }>) => { request.log.debug( { requestHeaders: request.headers, requestBody: request.body }, "Mutation Explain Request" ); const explainResponse = await withActiveSpan( tracer, "handleMutationExplain", () => connector.mutationExplain(configuration, state, request.body) ); request.log.debug( { responseBody: explainResponse }, "Mutation Explain Response" ); return explainResponse; } ); server.setErrorHandler(function(error, _request, reply) { // pino trace instrumentation will add trace information to log output this.log.error(error); if (error.validation) { reply.status(400).send({ message: "Validation Error - https://fastify.dev/docs/latest/Reference/Validation-and-Serialization#error-handling", details: error.validation, }); } else if (error instanceof ConnectorError) { // Send error response reply.status(error.statusCode).send({ message: error.message, details: error.details ?? {}, }); } else { const span = opentelemetry.trace.getActiveSpan(); span?.recordException(error); span?.setStatus({ code: SpanStatusCode.ERROR }); reply.status(500).send({ message: error.message, details: {}, }); } }); try { await server.listen({ port: options.port, host: options.host }); } catch (error) { server.log.error(error); process.exitCode = 1; } }