import morgan from 'morgan' ; import bodyParser from 'body-parser' ; import express from 'express' ; import CorsMiddleware from 'cors' ; import logger from '@mds-core/mds-logger' ; import { pathsFor , AuthorizationError } from '@mds-core/mds-utils' ; import { AuthorizationHeaderApiAuthorizer , ApiAuthorizer , AuthorizerClaims , ProviderIdClaim , UserEmailClaim , JurisdictionsClaim } from '@mds-core/mds-api-authorizer' ; import { Params , ParamsDictionary } from 'express-serve-static-core' ; export type ApiRequest < P extends Params = ParamsDictionary > = express . Request < P > ; export type ApiQuery < Q1 extends string , Q2 extends string [ ] = never > = { query : Partial < { [ P in Q1 ] : string ; } > & Partial < { [ P in Q2 [ number ] ] : string | string [ ] ; } > & { [ x ] : never ; } ; } ; export interface ApiResponse < L = unknown , B = unknown > extends express . Response < B | { error : unknown ; error_description ? : string ; error_details ? : string [ ] ; } | { errors : unknown [ ] ; } > { locals : L ; } export type ApiClaims < AccessTokenScope extends string > = { claims : AuthorizerClaims | null ; scopes : AccessTokenScope [ ] ; } ; export type ApiVersion < TVersion extends string > = { version : TVersion ; } ; export type ApiVersionedResponse < TVersion extends string , L = unknown , TBody = unknown > = ApiResponse < L & ApiVersion < TVersion > , TBody & ApiVersion < TVersion > > ; const about = ( ) => { const { versions : { node } , env : { npm_package_name : name , npm_package_version : version , npm_package_git_branch : branch , npm_package_git_commit : commit , npm_package_build_date : date , MAINTENANCE : maintenance } } = process ; return { name , version , build : { date , branch , commit } , node , status : maintenance ? `${ maintenance } (MAINTENANCE)` : 'Running' } ; } ; export const RequestLoggingMiddleware = < AccessTokenScope extends string > ( ) : express . RequestHandler => morgan ( ( tokens , req , res ) => [ ... ( res . locals . claims ?. provider_id ? [ res . locals . claims . provider_id ] : [ ] ) , tokens . method ( req , res ) , tokens . url ( req , res ) , tokens . status ( req , res ) , tokens . res ( req , res , 'content-length' ) , '-' , tokens [ 'response-time' ] ( req , res ) , 'ms' ] . join ( ' ' ) , { skip : ( req , res ) => { const { API_REQUEST_LOG_LEVEL = 0 } = process . env ; return res . statusCode < Number ( API_REQUEST_LOG_LEVEL ) ; } , stream : { write : msg => logger . info ( msg . slice ( 0 , - 1 ) ) } } ) ; export const JsonBodyParserMiddleware = ( options ) => bodyParser . json ( options ) ; export const MaintenanceModeMiddleware = ( ) => ( req , res , next ) => { if ( process . env . MAINTENANCE ) { return res . status ( 503 ) . send ( about ( ) ) ; } next ( ) ; } ; type AuthorizerMiddlewareOptions = { authorizer : ApiAuthorizer ; } ; export const AuthorizerMiddleware = ( { authorizer = AuthorizationHeaderApiAuthorizer } = { } ) => < AccessTokenScope extends string > ( req , res , next ) => { const claims = authorizer ( req ) ; res . locals . claims = claims ; res . locals . scopes = claims && claims . scope ? ( claims . scope . split ( ' ' ) as AccessTokenScope [ ] ) : [ ] ; next ( ) ; } ; const MinorVersion = ( version ) => { const [ major , minor ] = version . split ( '.' ) ; return `${ major }.${ minor }` ; } ; export const ApiVersionMiddleware = < TVersion extends string > ( mimeType , versions ) => ( { withDefaultVersion : ( preferred ) => async ( req , res , next ) => { const { accept : header } = req . headers ; const values = header ? header . split ( ',' ) . map ( value => value . trim ( ) ) : [ ] ; const accepted = values . reduce < { version : string ; q : number ; } [ ] | null > ( ( accept , value ) => { const [ mime , ... properties ] = value . split ( ';' ) . map ( property => property . trim ( ) ) ; return mime === mimeType ? ( accept ?? [ ] ) . concat ( { ... properties . reduce < { version : string ; q : number ; } > ( ( info , property ) => { const [ key , val ] = property . split ( '=' ) . map ( keyvalue => keyvalue . trim ( ) ) ; return { ... info , version : key === 'version' ? val : info . version , q : key === 'q' ? Number ( val ) : info . q } ; } , { version : preferred , q : 1.0 } ) } ) : accept ; } , null ) ?? [ { version : preferred , q : 1.0 } ] ; const supported = accepted . map ( info => ( { ... info , latest : versions . reduce < TVersion | undefined > ( ( latest , version ) => { if ( MinorVersion ( info . version ) === MinorVersion ( version ) ) { if ( latest ) { return latest > version ? latest : version ; } return version ; } return latest ; } , undefined ) } ) ) . filter ( info => info . latest !== undefined ) ; if ( req . method === 'OPTIONS' ) { if ( supported . length > 0 ) { const [ { latest } ] = supported . sort ( ( a , b ) => b . q - a . q ) ; if ( latest ) { res . locals . version = latest ; res . setHeader ( 'Content-Type' , `${ mimeType };version=${ MinorVersion ( latest ) }` ) ; return res . status ( 200 ) . send ( ) ; } } } else if ( supported . length > 0 ) { const [ { latest } ] = supported . sort ( ( a , b ) => b . q - a . q ) ; if ( latest ) { res . locals . version = latest ; res . setHeader ( 'Content-Type' , `${ mimeType };version=${ MinorVersion ( latest ) }` ) ; return next ( ) ; } } else if ( values . length === 0 ) { res . locals . version = preferred ; res . setHeader ( 'Content-Type' , `${ mimeType };version=${ MinorVersion ( preferred ) }` ) ; return next ( ) ; } return res . sendStatus ( 406 ) ; } } ) ; export const AboutRequestHandler = async ( req , res ) => { return res . status ( 200 ) . send ( about ( ) ) ; } ; export const HealthRequestHandler = async ( req , res ) => { return res . status ( 200 ) . send ( { ... about ( ) , process : process . pid , uptime : process . uptime ( ) , memory : process . memoryUsage ( ) } ) ; } ; const serverVersion = ( ) => { const { npm_package_name , npm_package_version , npm_package_git_commit } = process . env ; return npm_package_name && npm_package_version ? `${ npm_package_name } v${ npm_package_version } (${ npm_package_git_commit || 'local' })` : 'Server' ; } ; type HttpServerOptions = Partial < { port : string | number ; } > ; export const HttpServer = ( api , options = { } ) => { const { HTTP_KEEP_ALIVE_TIMEOUT = 15000 , HTTP_HEADERS_TIMEOUT = 20000 } = process . env ; const port = Number ( options . port || process . env . PORT || 4000 ) ; const server = api . listen ( port , ( ) => { logger . info ( `${ serverVersion ( ) } running on port ${ port }; Timeouts(${ HTTP_KEEP_ALIVE_TIMEOUT }/${ HTTP_HEADERS_TIMEOUT })` ) ; } ) ; server . keepAliveTimeout = Number ( HTTP_KEEP_ALIVE_TIMEOUT ) ; server . headersTimeout = Number ( HTTP_HEADERS_TIMEOUT ) ; return server ; } ; type CorsMiddlewareOptions = Omit < CorsMiddleware . CorsOptions , 'preflightContinue' > ; export const ApiServer = ( api , { authorizer , ... corsOptions } = { } , app = express ( ) ) : express . Express => { logger . info ( `${ serverVersion ( ) } starting` ) ; const claims = [ ProviderIdClaim , UserEmailClaim , JurisdictionsClaim ] ; claims . forEach ( claim => { logger . info ( `${ serverVersion ( ) } using authorization claim ${ claim ( ) }` ) ; } ) ; app . disable ( 'x-powered-by' ) ; app . use ( RequestLoggingMiddleware ( ) , CorsMiddleware ( { preflightContinue : true , ... corsOptions } ) , JsonBodyParserMiddleware ( { limit : '5mb' } ) , MaintenanceModeMiddleware ( ) , AuthorizerMiddleware ( { authorizer } ) ) ; app . get ( pathsFor ( '/' ) , AboutRequestHandler ) ; app . get ( pathsFor ( '/health' ) , HealthRequestHandler ) ; return api ( app ) ; } ; export type AccessTokenScopeValidator < AccessTokenScope extends string = never > = ( scopes : AccessTokenScope [ ] , claims : AuthorizerClaims | null ) => boolean | Promise < boolean > ; export const checkAccess = < AccessTokenScope extends string = never > ( validator ) => process . env . VERIFY_ACCESS_TOKEN_SCOPE === 'false' ? async ( req , res , next ) => { next ( ) ; } : async ( req , res , next ) => { const { scopes , claims } = res . locals ; const valid = await validator ( scopes , claims ) ; return valid ? next ( ) : res . status ( 403 ) . send ( { error : new AuthorizationError ( 'no access without scope' , { claims } ) } ) ; } ;