diff --git a/src/express/Route.ts b/src/express/Route.ts index c460277..7d16e5b 100644 --- a/src/express/Route.ts +++ b/src/express/Route.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; -import { ReaderTaskT, TaskT } from '@craigmiller160/ts-functions/types'; +import { IOT, ReaderTaskT, TaskT } from '@craigmiller160/ts-functions/types'; import { ExpressDependencies } from './ExpressDependencies'; export type Route = (req: Request, res: Response, next: NextFunction) => void; @@ -15,3 +15,9 @@ export type TaskRoute = ( res: Response, next: NextFunction ) => TaskT; + +export type IORoute = ( + req: Request, + res: Response, + next: NextFunction +) => IOT; diff --git a/src/express/auth/jwt.ts b/src/express/auth/jwt.ts index a1279df..975e5d4 100644 --- a/src/express/auth/jwt.ts +++ b/src/express/auth/jwt.ts @@ -2,20 +2,22 @@ import { Request } from 'express'; import * as Option from 'fp-ts/Option'; import { pipe } from 'fp-ts/function'; import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt'; -import * as Pred from 'fp-ts/Predicate'; +import { PredicateT, OptionT } from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; -export const isJwtInCookie: Pred.Predicate = (req) => +export const isJwtInCookie: PredicateT = (req) => pipe( - Option.fromNullable(process.env.COOKIE_NAME), - Option.chain((_) => Option.fromNullable(req.cookies[_])), - Option.isSome - ); + Process.envLookupO('COOKIE_NAME'), + IO.map(Option.chain((_) => Option.fromNullable(req.cookies[_]))), + IO.map(Option.isSome) + )(); -const getJwtFromCookie = (req: Request): Option.Option => +const getJwtFromCookie = (req: Request): OptionT => pipe( - Option.fromNullable(process.env.COOKIE_NAME), - Option.chain((_) => Option.fromNullable(req.cookies[_])) - ); + Process.envLookupO('COOKIE_NAME'), + IO.map(Option.chain((_) => Option.fromNullable(req.cookies[_]))) + )(); export const jwtFromRequest: JwtFromRequestFunction = (req) => pipe( diff --git a/src/express/auth/passport.ts b/src/express/auth/passport.ts index 08a94b5..9013348 100644 --- a/src/express/auth/passport.ts +++ b/src/express/auth/passport.ts @@ -2,30 +2,37 @@ import { logger } from '../../logger'; import { Strategy as JwtStrategy, StrategyOptions } from 'passport-jwt'; import passport from 'passport'; import { pipe } from 'fp-ts/function'; -import * as Either from 'fp-ts/Either'; import { UnauthorizedError } from '../../error/UnauthorizedError'; import { AccessToken } from './AccessToken'; import * as Pred from 'fp-ts/Predicate'; -import * as Try from '@craigmiller160/ts-functions/Try'; import { jwtFromRequest } from './jwt'; -import { getRequiredValues } from '../../function/Values'; -import { ReaderT } from '@craigmiller160/ts-functions/types'; +import { + IOT, + IOTryT, + OptionT, + ReaderT +} from '@craigmiller160/ts-functions/types'; import { ExpressDependencies } from '../ExpressDependencies'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import { getRequiredValues } from '../../function/Values'; +import * as IO from 'fp-ts/IO'; +import * as IOEither from 'fp-ts/IOEither'; interface ClientKeyName { readonly clientKey: string; readonly clientName: string; } -const getClientKeyAndName = (): Try.Try => { - const envArray: ReadonlyArray = [ - process.env.CLIENT_KEY, - process.env.CLIENT_NAME +const getClientKeyAndName = (): IOTryT => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('CLIENT_KEY'), + Process.envLookupO('CLIENT_NAME') ]; return pipe( - getRequiredValues(envArray), - Either.map( + IO.sequenceArray(envArray), + IO.map(getRequiredValues), + IOEither.map( ([clientKey, clientName]): ClientKeyName => ({ clientKey, clientName @@ -54,18 +61,22 @@ export const createPassportValidation: ReaderT = ({ const doValidatePayload = validatePayload(payload); pipe( getClientKeyAndName(), - Either.filterOrElse( + IOEither.filterOrElse( doValidatePayload, () => new UnauthorizedError( 'Invalid token payload attributes' ) ), - Either.fold( - (ex) => done(ex, null), - () => done(null, payload) + IOEither.fold( + (ex) => () => { + done(ex, null); + }, + () => () => { + done(null, payload); + } ) - ); + )(); }) ); }; diff --git a/src/express/controllers/oauth.ts b/src/express/controllers/oauth.ts index 33e02b7..b6f1126 100644 --- a/src/express/controllers/oauth.ts +++ b/src/express/controllers/oauth.ts @@ -1,12 +1,12 @@ import { secure, secureReaderTask } from '../auth/secure'; import * as oAuthService from '../../services/routes/OAuthService'; -import * as Reader from 'fp-ts/Reader'; import { Controller } from './Controller'; import { readerTaskRouteToReaderRoute } from '../readerTaskRouteToReaderRoute'; +import { ioRouteToReaderRoute } from '../ioRouteToReaderRoute'; export const getAuthUser: Controller = secure(oAuthService.getAuthUser); -export const getAuthCodeLogin: Controller = Reader.of( +export const getAuthCodeLogin: Controller = ioRouteToReaderRoute( oAuthService.getAuthCodeLogin ); diff --git a/src/express/expressErrorHandler.ts b/src/express/expressErrorHandler.ts index 950d7e5..0d3cc99 100644 --- a/src/express/expressErrorHandler.ts +++ b/src/express/expressErrorHandler.ts @@ -8,7 +8,6 @@ import { pipe } from 'fp-ts/function'; import { ReaderT } from '@craigmiller160/ts-functions/types'; import * as Reader from 'fp-ts/Reader'; import { ExpressDependencies } from './ExpressDependencies'; -import { emergencyErrorLog } from '../logger/emergencyErrorLog'; interface ErrorResponse { readonly timestamp: string; @@ -49,7 +48,6 @@ const createErrorResponse = ( const fullQueryString = queryString.length > 0 ? `?${queryString}` : ''; const timestamp = format(new Date(), 'yyyy-MM-dd HH:mm:ss.SSS'); - emergencyErrorLog(timestamp, err); return { timestamp, diff --git a/src/express/index.ts b/src/express/index.ts index cf486ab..097f5b9 100644 --- a/src/express/index.ts +++ b/src/express/index.ts @@ -1,7 +1,12 @@ import * as Option from 'fp-ts/Option'; import * as TaskEither from 'fp-ts/TaskEither'; -import { TaskTryT, OptionT, TaskT } from '@craigmiller160/ts-functions/types'; - +import { + TaskTryT, + OptionT, + TaskT, + IOT +} from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; import bodyParer from 'body-parser'; import { logger } from '../logger'; import { flow, pipe } from 'fp-ts/function'; @@ -27,16 +32,22 @@ import { watchlistRepository } from '../data/repo'; import * as Reader from 'fp-ts/Reader'; +import * as IO from 'fp-ts/IO'; +import * as IOEither from 'fp-ts/IOEither'; const safeParseInt = (text: string): OptionT => match(parseInt(text)) .with(__.NaN, () => Option.none) .otherwise((_) => Option.some(_)); -const expressListen = (app: Express, port: number): TaskTryT => { +const expressListen = ( + app: Express, + port: number, + nodeEnv: string +): TaskTryT => { const server = https.createServer(httpsOptions, app); - return match(process.env.NODE_ENV) + return match(nodeEnv) .with('test', () => TaskEither.right(server)) .otherwise(() => wrapListen(server, port)); }; @@ -99,24 +110,32 @@ const createExpressApp = (tokenKey: TokenKey): Express => { return app; }; -const getPort = (): number => +const getPort = (): IOT => pipe( - Option.fromNullable(process.env.EXPRESS_PORT), - Option.chain(safeParseInt), - Option.getOrElse(() => 8080) + Process.envLookupO('EXPRESS_PORT'), + IO.map( + flow( + Option.chain(safeParseInt), + Option.getOrElse(() => 8080) + ) + ) ); export const startExpressServer = ( tokenKey: TokenKey ): TaskTryT => { - const port = getPort(); - logger.debug('Starting server'); const app = createExpressApp(tokenKey); return pipe( - expressListen(app, port), + IOEither.fromIO(getPort()), + IOEither.bindTo('port'), + IOEither.bind('nodeEnv', () => Process.envLookupE('NODE_ENV')), + TaskEither.fromIOEither, + TaskEither.chain(({ port, nodeEnv }) => + expressListen(app, port, nodeEnv) + ), TaskEither.map((_) => ({ server: _, app diff --git a/src/express/ioRouteToReaderRoute.ts b/src/express/ioRouteToReaderRoute.ts new file mode 100644 index 0000000..17420cc --- /dev/null +++ b/src/express/ioRouteToReaderRoute.ts @@ -0,0 +1,9 @@ +import { ReaderT } from '@craigmiller160/ts-functions/types'; +import { IORoute, Route } from './Route'; +import { ExpressDependencies } from './ExpressDependencies'; + +export const ioRouteToReaderRoute = + (fn: IORoute): ReaderT => + () => + (req, res, next) => + fn(req, res, next)(); diff --git a/src/function/Values.ts b/src/function/Values.ts index 76fd7e2..3122ed5 100644 --- a/src/function/Values.ts +++ b/src/function/Values.ts @@ -3,12 +3,31 @@ import * as Option from 'fp-ts/Option'; import { flow, pipe } from 'fp-ts/function'; import { MissingValuesError } from '../error/MissingValuesError'; import { + OptionT, ReadonlyNonEmptyArrayT, TryT } from '@craigmiller160/ts-functions/types'; import * as RNonEmptyArray from 'fp-ts/ReadonlyNonEmptyArray'; +import * as Json from '@craigmiller160/ts-functions/Json'; export const getRequiredValues = ( + valuesArray: ReadonlyArray> +): TryT> => + pipe( + valuesArray, + Option.sequenceArray, + Either.fromOption( + () => + new MissingValuesError( + `Missing required values: ${Option.getOrElse(() => 'Error')( + Json.stringifyO(valuesArray) + )}` + ) + ) + ); + +// TODO delete this +export const getRequiredValues2 = ( valuesArray: ReadonlyArray ): TryT> => pipe( diff --git a/src/index.ts b/src/index.ts index 9f8ebc8..bb6435b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import * as TaskEither from 'fp-ts/TaskEither'; import { startExpressServer } from './express'; import { logAndReturn, logger } from './logger'; import { loadTokenKey } from './services/auth/TokenKey'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as Task from 'fp-ts/Task'; logger.info('Starting application'); @@ -13,8 +15,8 @@ pipe( TaskEither.chainFirst(connectToMongo), TaskEither.chain(startExpressServer), TaskEither.mapLeft(logAndReturn('error', 'Error starting application')), - TaskEither.mapLeft((_) => { - process.exit(1); - return _; - }) + TaskEither.fold( + () => Task.fromIO(Process.exit(1)), + () => async () => '' + ) )(); diff --git a/src/logger/emergencyErrorLog.ts b/src/logger/emergencyErrorLog.ts deleted file mode 100644 index a67e0ed..0000000 --- a/src/logger/emergencyErrorLog.ts +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'path'; -import * as Option from 'fp-ts/Option'; -import { pipe } from 'fp-ts/function'; -import * as File from '@craigmiller160/ts-functions/File'; -import * as IOEither from 'fp-ts/IOEither'; -import { logAndReturn } from './index'; - -const LOG_FILE_PATH = path.join(process.cwd(), 'emergency-log.txt'); -type BooleanString = 'true' | 'false'; - -const useEmergencyErrorLog = (): BooleanString => - pipe( - Option.fromNullable( - process.env.USE_EMERGENCY_ERROR_LOG as BooleanString | undefined - ), - Option.getOrElse((): BooleanString => 'false') - ); - -const appendToLogFile = File.appendFileSync(LOG_FILE_PATH); - -export const emergencyErrorLog = (timestamp: string, err: Error) => { - if (useEmergencyErrorLog() === 'true') { - pipe( - appendToLogFile(`${timestamp}\n`), - IOEither.chain(() => appendToLogFile(`${err.stack ?? ''}\n`)), - IOEither.mapLeft( - logAndReturn('error', 'Error writing to emergency error log') - ) - )(); - } -}; diff --git a/src/logger/index.ts b/src/logger/index.ts index 3e94c00..127674a 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -7,6 +7,10 @@ import path from 'path'; import * as RArray from 'fp-ts/ReadonlyArray'; import * as RArrayExt from '@craigmiller160/ts-functions/ReadonlyArrayExt'; import { pipe } from 'fp-ts/function'; +import { PredicateT } from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as Option from 'fp-ts/Option'; +import * as IO from 'fp-ts/IO'; type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'verbose'; @@ -15,25 +19,34 @@ const myFormat = format.printf( `[${timestamp}] [${level}] - ${stack ?? message}` ); -const isNotProduction = (): boolean => process.env.NODE_ENV !== 'production'; +const isNotProduction: PredicateT = pipe( + Process.envLookupO('NODE_ENV'), + IO.map(Option.filter((_) => _ !== 'production')), + IO.map(Option.isSome) +); const theTransports: ReadonlyArray = pipe( - [ - new transports.Console(), - isNotProduction() - ? new transports.File({ - filename: path.join( - process.cwd(), - 'logs', - 'market-tracker.log' - ), - maxsize: 100_000, - maxFiles: 10 - }) - : null - ], - RArray.filter((_) => _ !== null) -) as ReadonlyArray; + Process.cwd(), + IO.map((cwd) => + pipe( + [ + new transports.Console(), + isNotProduction() + ? new transports.File({ + filename: path.join( + cwd, + 'logs', + 'market-tracker.log' + ), + maxsize: 100_000, + maxFiles: 10 + }) + : null + ], + RArray.filter((_) => _ !== null) + ) + ) +)() as ReadonlyArray; export const logger = createLogger({ level: 'debug', diff --git a/src/mongo/connectionString.ts b/src/mongo/connectionString.ts index 0a70238..b1c3da5 100644 --- a/src/mongo/connectionString.ts +++ b/src/mongo/connectionString.ts @@ -1,10 +1,13 @@ -import * as Try from '@craigmiller160/ts-functions/Try'; -import { pipe } from 'fp-ts/function'; -import * as O from 'fp-ts/Option'; -import * as E from 'fp-ts/Either'; +import { flow, pipe } from 'fp-ts/function'; +import * as Option from 'fp-ts/Option'; +import * as Either from 'fp-ts/Either'; import { logger } from '../logger'; -import * as A from 'fp-ts/Array'; import { match } from 'ts-pattern'; +import { IOT, IOTryT, OptionT } from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; +import { getRequiredValues } from '../function/Values'; +import * as IOEither from 'fp-ts/IOEither'; interface MongoEnv { readonly hostname: string; @@ -18,13 +21,22 @@ interface MongoEnv { const createConnectionString = (env: MongoEnv): string => `mongodb://${env.user}:${env.password}@${env.hostname}:${env.port}/${env.db}?authSource=${env.adminDb}&tls=true&tlsAllowInvalidCertificates=true&tlsAllowInvalidHostnames=true`; -const logConnectionStringInDev = (connectionString: string): string => - match(process.env.NODE_ENV) +interface ConnStringAndEnv { + readonly connectionString: string; + readonly nodeEnv: string; +} + +const logConnectionStringInDev = ( + connStringAndEnv: ConnStringAndEnv +): ConnStringAndEnv => + match(connStringAndEnv.nodeEnv) .with('development', () => { - logger.debug(`Mongo Connection String: ${connectionString}`); - return connectionString; + logger.debug( + `Mongo Connection String: ${connStringAndEnv.connectionString}` + ); + return connStringAndEnv; }) - .otherwise(() => connectionString); + .otherwise(() => connStringAndEnv); const envToMongoEnv = ([ hostname, @@ -42,52 +54,40 @@ const envToMongoEnv = ([ db }); -const nullableEnvToMongoEnv = ([ - hostname, - port, - user, - password, - adminDb, - db -]: ReadonlyArray): Partial => ({ - hostname, - port, - user, - password, - adminDb, - db -}); - -const getMongoPasswordEnv = (): string | undefined => +const getMongoPasswordEnv = (): IOT> => pipe( - O.fromNullable(process.env.MONGO_PASSWORD), - O.getOrElse(() => process.env.MONGO_ROOT_PASSWORD) + Process.envLookupO('MONGO_PASSWORD'), + IO.chain( + Option.fold( + () => Process.envLookupO('MONGO_ROOT_PASSWORD'), + (_) => () => Option.some(_) + ) + ) ); -export const getConnectionString = (): Try.Try => { - const nullableEnvArray: Array = [ - process.env.MONGO_HOSTNAME, - process.env.MONGO_PORT, - process.env.MONGO_USER, +export const getConnectionString = (): IOTryT => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('MONGO_HOSTNAME'), + Process.envLookupO('MONGO_PORT'), + Process.envLookupO('MONGO_USER'), getMongoPasswordEnv(), - process.env.MONGO_AUTH_DB, - process.env.MONGO_DB + Process.envLookupO('MONGO_AUTH_DB'), + Process.envLookupO('MONGO_DB') ]; return pipe( - nullableEnvArray, - A.map(O.fromNullable), - O.sequenceArray, - O.map(envToMongoEnv), - O.map(createConnectionString), - O.map(logConnectionStringInDev), - E.fromOption( - () => - new Error( - `Missing environment variables for Mongo connection: ${JSON.stringify( - nullableEnvToMongoEnv(nullableEnvArray) - )}` - ) - ) + envArray, + IO.sequenceArray, + IO.map( + flow( + getRequiredValues, + Either.map(envToMongoEnv), + Either.map(createConnectionString) + ) + ), + IOEither.bindTo('connectionString'), + IOEither.bind('nodeEnv', () => Process.envLookupE('NODE_ENV')), + IOEither.map(logConnectionStringInDev), + IOEither.map(({ connectionString }) => connectionString) ); }; diff --git a/src/mongo/index.ts b/src/mongo/index.ts index 877709b..5a8715d 100644 --- a/src/mongo/index.ts +++ b/src/mongo/index.ts @@ -13,7 +13,7 @@ const connectToMongoose = ( export const connectToMongo = (): TaskTry.TaskTry => pipe( getConnectionString(), - TaskEither.fromEither, + TaskEither.fromIOEither, TaskEither.map(logAndReturn('debug', 'Connecting to MongoDB')), TaskEither.chain(connectToMongoose), TaskEither.map(logAndReturn('info', 'Connected to MongoDB')) diff --git a/src/services/auth/AuthCodeAuthentication.ts b/src/services/auth/AuthCodeAuthentication.ts index 0f28f5b..4809e2d 100644 --- a/src/services/auth/AuthCodeAuthentication.ts +++ b/src/services/auth/AuthCodeAuthentication.ts @@ -3,7 +3,6 @@ import { getMarketTrackerSession } from '../../function/HttpRequest'; import * as Option from 'fp-ts/Option'; import * as Either from 'fp-ts/Either'; import { flow, pipe } from 'fp-ts/function'; -import * as IOEither from 'fp-ts/IOEither'; import { TokenResponse } from '../../types/TokenResponse'; import * as Try from '@craigmiller160/ts-functions/Try'; import { createTokenCookie } from './Cookie'; @@ -13,9 +12,18 @@ import { UnauthorizedError } from '../../error/UnauthorizedError'; import { sendTokenRequest } from './AuthServerRequest'; import { getRequiredValues } from '../../function/Values'; import { AppRefreshToken } from '../../data/modelTypes/AppRefreshToken'; -import { IOT, ReaderTaskTryT, TryT } from '@craigmiller160/ts-functions/types'; +import { + IOT, + IOTryT, + OptionT, + ReaderTaskTryT, + TryT +} from '@craigmiller160/ts-functions/types'; import { ExpressDependencies } from '../../express/ExpressDependencies'; import * as ReaderTaskEither from 'fp-ts/ReaderTaskEither'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; +import * as IOEither from 'fp-ts/IOEither'; export interface AuthCodeSuccess { readonly cookie: string; @@ -104,31 +112,14 @@ const handleRefreshToken = return appRefreshTokenRepository.saveRefreshToken(refreshToken); }; -const prepareRedirect = (): TryT => - pipe( - Option.fromNullable(process.env.POST_AUTH_REDIRECT), - Either.fromOption( - () => - new UnauthorizedError( - 'No post-auth redirect available for auth code login' - ) - ) - ); - const getCodeAndState = (req: Request): TryT<[string, number]> => { - const nullableQueryArray: ReadonlyArray = [ - req.query.code as string | undefined, - req.query.state as string | undefined + const nullableQueryArray: ReadonlyArray> = [ + Option.fromNullable(req.query.code as string | undefined), + Option.fromNullable(req.query.state as string | undefined) ]; return pipe( getRequiredValues(nullableQueryArray), - Either.mapLeft( - () => - new UnauthorizedError( - `Missing required query params for authentication: ${nullableQueryArray}` - ) - ), Either.bindTo('parts'), Either.bind('state', ({ parts: [, stateString] }) => Try.tryCatch(() => parseInt(stateString)) @@ -158,15 +149,16 @@ const getAndValidateCodeOriginAndState = (req: Request): TryT => const createAuthCodeBody = ( origin: string, code: string -): TryT => { - const envArray: ReadonlyArray = [ - process.env.CLIENT_KEY, - process.env.AUTH_CODE_REDIRECT_URI +): IOTryT => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('CLIENT_KEY'), + Process.envLookupO('AUTH_CODE_REDIRECT_URI') ]; return pipe( - getRequiredValues(envArray), - Either.map( + IO.sequenceArray(envArray), + IO.map(getRequiredValues), + IOEither.map( ([clientKey, redirectUri]): AuthCodeBody => ({ grant_type: 'authorization_code', client_id: clientKey, @@ -182,17 +174,20 @@ export const authenticateWithAuthCode = ( ): ReaderTaskTryT => pipe( getAndValidateCodeOriginAndState(req), - Either.chain(({ origin, code }) => createAuthCodeBody(origin, code)), - ReaderTaskEither.fromEither, + IOEither.fromEither, + IOEither.chain(({ origin, code }) => createAuthCodeBody(origin, code)), + ReaderTaskEither.fromIOEither, ReaderTaskEither.chain( flow(sendTokenRequest, ReaderTaskEither.fromTaskEither) ), ReaderTaskEither.chainFirst(handleRefreshToken), ReaderTaskEither.chain((_) => - ReaderTaskEither.fromEither(createTokenCookie(_.accessToken)) + ReaderTaskEither.fromIOEither(createTokenCookie(_.accessToken)) ), ReaderTaskEither.bindTo('cookie'), ReaderTaskEither.bind('postAuthRedirect', () => - ReaderTaskEither.fromEither(prepareRedirect()) + ReaderTaskEither.fromIOEither( + Process.envLookupE('POST_AUTH_REDIRECT') + ) ) ); diff --git a/src/services/auth/AuthCodeLogin.ts b/src/services/auth/AuthCodeLogin.ts index 5eb1c4d..e8f9438 100644 --- a/src/services/auth/AuthCodeLogin.ts +++ b/src/services/auth/AuthCodeLogin.ts @@ -1,16 +1,17 @@ import { Request } from 'express'; -import * as E from 'fp-ts/Either'; -import * as O from 'fp-ts/Option'; +import * as Either from 'fp-ts/Either'; import { pipe } from 'fp-ts/function'; import { randomInt } from 'crypto'; -import * as A from 'fp-ts/Array'; import * as IO from 'fp-ts/IO'; -import * as IOE from 'fp-ts/IOEither'; import * as Uri from '@craigmiller160/ts-functions/Uri'; import { getHeader, getMarketTrackerSession } from '../../function/HttpRequest'; import * as Time from '@craigmiller160/ts-functions/Time'; import { STATE_EXP_FORMAT } from './constants'; import { UnauthorizedError } from '../../error/UnauthorizedError'; +import { IOT, IOTryT, OptionT, TryT } from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import { getRequiredValues } from '../../function/Values'; +import * as IOEither from 'fp-ts/IOEither'; export interface AuthCodeLoginResponse { readonly url: string; @@ -18,16 +19,16 @@ export interface AuthCodeLoginResponse { const AUTH_CODE_LOGIN_PATH = '/ui/login'; -const getOrigin = (req: Request): E.Either => +const getOrigin = (req: Request): TryT => pipe( getHeader(req, 'Origin'), - E.fromOption( + Either.fromOption( () => new UnauthorizedError('Missing origin header on request') ) ); const storeAuthCodeLoginSessionValues = - (req: Request, state: number, origin: string): IO.IO => + (req: Request, state: number, origin: string): IOT => () => { const session = getMarketTrackerSession(req); session.state = state; @@ -43,58 +44,50 @@ const createUrl = ( envVariables: readonly string[], origin: string, state: number -): E.Either => { +): Either.Either => { const [clientKey, authCodeRedirectUri, authLoginBaseUri] = envVariables; const baseUrl = `${origin}${authLoginBaseUri}${AUTH_CODE_LOGIN_PATH}`; const fullRedirectUri = `${origin}${authCodeRedirectUri}`; return pipe( - E.sequenceArray([ + Either.sequenceArray([ Uri.encode(clientKey), Uri.encode(fullRedirectUri), Uri.encode(state) ]), - E.map( + Either.map( ([encodedClientKey, encodedRedirectUri, encodedState]) => `response_type=code&client_id=${encodedClientKey}&redirect_uri=${encodedRedirectUri}&state=${encodedState}` ), - E.map((queryString) => `${baseUrl}?${queryString}`) + Either.map((queryString) => `${baseUrl}?${queryString}`) ); }; const buildAuthCodeLoginUrl = ( origin: string, state: number -): E.Either => { - const nullableEnvArray: Array = [ - process.env.CLIENT_KEY, - process.env.AUTH_CODE_REDIRECT_URI, - process.env.AUTH_LOGIN_BASE_URI +): IOTryT => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('CLIENT_KEY'), + Process.envLookupO('AUTH_CODE_REDIRECT_URI'), + Process.envLookupO('AUTH_LOGIN_BASE_URI') ]; return pipe( - nullableEnvArray, - A.map(O.fromNullable), - O.sequenceArray, - E.fromOption( - () => - new UnauthorizedError( - `Missing environment variables for auth code login URL: ${nullableEnvArray}` - ) - ), - E.chain((_) => createUrl(_, origin, state)) + IO.sequenceArray(envArray), + IO.map(getRequiredValues), + IOEither.chainEitherK((_) => createUrl(_, origin, state)) ); }; -export const prepareAuthCodeLogin = (req: Request): E.Either => { +export const prepareAuthCodeLogin = (req: Request): IOTryT => { const state = randomInt(1_000_000_000); return pipe( getOrigin(req), - E.chainFirst((_) => - IOE.fromIO( - storeAuthCodeLoginSessionValues(req, state, _) - )() + IOEither.fromEither, + IOEither.chainFirstIOK((origin) => + storeAuthCodeLoginSessionValues(req, state, origin) ), - E.chain((origin) => buildAuthCodeLoginUrl(origin, state)) + IOEither.chain((origin) => buildAuthCodeLoginUrl(origin, state)) ); }; diff --git a/src/services/auth/AuthServerRequest.ts b/src/services/auth/AuthServerRequest.ts index 6974a4b..bfd6cd6 100644 --- a/src/services/auth/AuthServerRequest.ts +++ b/src/services/auth/AuthServerRequest.ts @@ -1,6 +1,3 @@ -import * as Option from 'fp-ts/Option'; -import * as Either from 'fp-ts/Either'; -import * as Try from '@craigmiller160/ts-functions/Try'; import * as TaskTry from '@craigmiller160/ts-functions/TaskTry'; import * as TaskEither from 'fp-ts/TaskEither'; import { pipe } from 'fp-ts/function'; @@ -10,34 +7,36 @@ import { TokenResponse } from '../../types/TokenResponse'; import { restClient } from '../RestClient'; import { logAndReturn } from '../../logger'; import { getRequiredValues } from '../../function/Values'; -import { TaskTryT } from '@craigmiller160/ts-functions/types'; +import { + IOT, + IOTryT, + OptionT, + TaskTryT +} from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; +import * as IOEither from 'fp-ts/IOEither'; +import * as IOTry from '@craigmiller160/ts-functions/IOTry'; const TOKEN_PATH = '/oauth/token'; -const getBasicAuth = (): Try.Try => { - const envArray: ReadonlyArray = [ - process.env.CLIENT_KEY, - process.env.CLIENT_SECRET +const getBasicAuth = (): IOTryT => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('CLIENT_KEY'), + Process.envLookupO('CLIENT_SECRET') ]; return pipe( - getRequiredValues(envArray), - Either.chain(([clientKey, clientSecret]) => - Try.tryCatch(() => + IO.sequenceArray(envArray), + IO.map(getRequiredValues), + IOEither.chain(([clientKey, clientSecret]) => + IOTry.tryCatch(() => Buffer.from(`${clientKey}:${clientSecret}`).toString('base64') ) ) ); }; -const getAuthServerHost = (): Try.Try => - pipe( - Option.fromNullable(process.env.AUTH_SERVER_HOST), - Either.fromOption( - () => new UnauthorizedError('Missing authorization server host') - ) - ); - const executeTokenRestCall = ( authServerHost: string, body: string, @@ -70,10 +69,10 @@ export const sendTokenRequest = ( ): TaskTryT => { const formattedRequestBody = qs.stringify(requestBody); return pipe( - getAuthServerHost(), - Either.bindTo('authServerHost'), - Either.bind('basicAuth', getBasicAuth), - TaskEither.fromEither, + Process.envLookupE('AUTH_SERVER_HOST'), + IOEither.bindTo('authServerHost'), + IOEither.bind('basicAuth', getBasicAuth), + TaskEither.fromIOEither, TaskEither.chain(({ basicAuth, authServerHost }) => executeTokenRestCall( authServerHost, diff --git a/src/services/auth/Cookie.ts b/src/services/auth/Cookie.ts index 069a531..b67c25b 100644 --- a/src/services/auth/Cookie.ts +++ b/src/services/auth/Cookie.ts @@ -1,8 +1,9 @@ import { pipe } from 'fp-ts/function'; -import * as E from 'fp-ts/Either'; -import * as O from 'fp-ts/Option'; -import * as A from 'fp-ts/Array'; -import { UnauthorizedError } from '../../error/UnauthorizedError'; +import { IOT, IOTryT, OptionT } from '@craigmiller160/ts-functions/types'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; +import { getRequiredValues } from '../../function/Values'; +import * as IOEither from 'fp-ts/IOEither'; const createCookie = ( cookieName: string, @@ -12,40 +13,28 @@ const createCookie = ( ): string => `${cookieName}=${value}; Max-Age=${maxAgeSecs}; Secure; HttpOnly; SameSite=strict; Path=${cookiePath}`; -export const getEmptyCookie = (): E.Either => +export const getEmptyCookie = (): IOTryT => pipe( getCookieEnv(), - E.map(([cookieName, , cookiePath]) => + IOEither.map(([cookieName, , cookiePath]) => createCookie(cookieName, '', '0', cookiePath) ) ); -const getCookieEnv = (): E.Either => { - const nullableEnvArray: Array = [ - process.env.COOKIE_NAME, - process.env.COOKIE_MAX_AGE_SECS, - process.env.COOKIE_PATH +const getCookieEnv = (): IOTryT> => { + const envArray: ReadonlyArray>> = [ + Process.envLookupO('COOKIE_NAME'), + Process.envLookupO('COOKIE_MAX_AGE_SECS'), + Process.envLookupO('COOKIE_PATH') ]; - return pipe( - nullableEnvArray, - A.map(O.fromNullable), - O.sequenceArray, - E.fromOption( - () => - new UnauthorizedError( - `Missing environment variables for setting cookie: ${nullableEnvArray}` - ) - ) - ); + return pipe(IO.sequenceArray(envArray), IO.map(getRequiredValues)); }; -export const createTokenCookie = ( - accessToken: string -): E.Either => +export const createTokenCookie = (accessToken: string): IOTryT => pipe( getCookieEnv(), - E.map(([cookieName, cookieMaxAgeSecs, cookiePath]) => + IOEither.map(([cookieName, cookieMaxAgeSecs, cookiePath]) => createCookie(cookieName, accessToken, cookieMaxAgeSecs, cookiePath) ) ); diff --git a/src/services/auth/Logout.ts b/src/services/auth/Logout.ts index f576058..fa5d0d6 100644 --- a/src/services/auth/Logout.ts +++ b/src/services/auth/Logout.ts @@ -26,6 +26,6 @@ export const logout = ( ), ReaderTaskEither.chainFirst(deleteRefreshToken), ReaderTaskEither.chain(() => - ReaderTaskEither.fromEither(getEmptyCookie()) + ReaderTaskEither.fromIOEither(getEmptyCookie()) ) ); diff --git a/src/services/auth/RefreshExpiredToken.ts b/src/services/auth/RefreshExpiredToken.ts index cfcdbb1..2debdc7 100644 --- a/src/services/auth/RefreshExpiredToken.ts +++ b/src/services/auth/RefreshExpiredToken.ts @@ -108,7 +108,7 @@ export const refreshExpiredToken = ( handleRefreshToken(existingTokenId, tokenResponse) ), ReaderTaskEither.chain(({ tokenResponse: { accessToken } }) => - ReaderTaskEither.fromEither(createTokenCookie(accessToken)) + ReaderTaskEither.fromIOEither(createTokenCookie(accessToken)) ) ); }; diff --git a/src/services/auth/TokenKey.ts b/src/services/auth/TokenKey.ts index d9b91a1..dc18160 100644 --- a/src/services/auth/TokenKey.ts +++ b/src/services/auth/TokenKey.ts @@ -1,12 +1,12 @@ import jwkToPem, { JWK } from 'jwk-to-pem'; -import * as E from 'fp-ts/Either'; +import * as Either from 'fp-ts/Either'; import { pipe } from 'fp-ts/function'; -import * as O from 'fp-ts/Option'; import * as TaskEither from 'fp-ts/TaskEither'; import * as TaskTry from '@craigmiller160/ts-functions/TaskTry'; import { restClient } from '../RestClient'; import * as Try from '@craigmiller160/ts-functions/Try'; import { logAndReturn } from '../../logger'; +import * as Process from '@craigmiller160/ts-functions/Process'; export interface TokenKey { readonly key: string; @@ -18,14 +18,6 @@ export interface JwkSet { readonly keys: JWK[]; } -const getAuthServerHost = (): E.Either => - pipe( - O.fromNullable(process.env.AUTH_SERVER_HOST), - E.fromOption( - () => new Error('Auth Server Host variable is not available') - ) - ); - const getJwkSetFromAuthServer = ( authServerHost: string ): TaskEither.TaskEither => @@ -41,7 +33,7 @@ const convertJwkToPem = ( ): TaskEither.TaskEither => pipe( Try.tryCatch(() => jwkToPem(jwkSet.keys[0])), - E.map( + Either.map( (_): TokenKey => ({ key: _ }) @@ -51,8 +43,8 @@ const convertJwkToPem = ( export const loadTokenKey = (): TaskEither.TaskEither => pipe( - getAuthServerHost(), - TaskEither.fromEither, + Process.envLookupE('AUTH_SERVER_HOST'), + TaskEither.fromIOEither, TaskEither.map(logAndReturn('debug', 'Loading JWK')), TaskEither.chain(getJwkSetFromAuthServer), TaskEither.chain(convertJwkToPem), diff --git a/src/services/routes/CoinGeckoService.ts b/src/services/routes/CoinGeckoService.ts index 2eb22eb..fb59ac9 100644 --- a/src/services/routes/CoinGeckoService.ts +++ b/src/services/routes/CoinGeckoService.ts @@ -1,9 +1,7 @@ import { NextFunction, Request, Response } from 'express'; -import { TaskT, TaskTryT, TryT } from '@craigmiller160/ts-functions/types'; +import { IOTryT, TaskT, TaskTryT } from '@craigmiller160/ts-functions/types'; import { getRequiredValues } from '../../function/Values'; import { pipe, identity, flow } from 'fp-ts/function'; -import * as Either from 'fp-ts/Either'; -import * as RNonEmptyArray from 'fp-ts/ReadonlyNonEmptyArray'; import * as TaskEither from 'fp-ts/TaskEither'; import { match, when } from 'ts-pattern'; import qs from 'qs'; @@ -14,11 +12,23 @@ import { AxiosError } from 'axios'; import * as Option from 'fp-ts/Option'; import { CryptoGeckoError } from '../../error/CryptoGeckoError'; import * as Json from '@craigmiller160/ts-functions/Json'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; +import * as IOEither from 'fp-ts/IOEither'; +import * as RArray from 'fp-ts/ReadonlyArray'; -const getCoinGeckoEnv = (): TryT => +const getCoinGeckoEnv = (): IOTryT => pipe( - getRequiredValues([process.env.COIN_GECKO_BASE_URL]), - Either.map(RNonEmptyArray.head) + IO.sequenceArray([Process.envLookupO('COIN_GECKO_BASE_URL')]), + IO.map(getRequiredValues), + IOEither.chain( + flow( + RArray.head, + IOEither.fromOption( + () => new Error('Cannot find CoinGecko env') + ) + ) + ) ); const isNotEmpty = (text: string) => text.length > 0; @@ -90,7 +100,7 @@ export const queryCoinGecko = ( ): TaskT => pipe( getCoinGeckoEnv(), - TaskEither.fromEither, + TaskEither.fromIOEither, TaskEither.chain((baseUrl) => sendCryptoGeckoRequest(baseUrl, req.path, req.query) ), diff --git a/src/services/routes/OAuthService.ts b/src/services/routes/OAuthService.ts index 3335066..3e4f100 100644 --- a/src/services/routes/OAuthService.ts +++ b/src/services/routes/OAuthService.ts @@ -1,17 +1,17 @@ import { NextFunction, Request, Response } from 'express'; import { AccessToken } from '../../express/auth/AccessToken'; import { pipe } from 'fp-ts/function'; -import * as Either from 'fp-ts/Either'; import { AuthCodeLoginResponse, prepareAuthCodeLogin } from '../auth/AuthCodeLogin'; -import { ReaderTaskT } from '@craigmiller160/ts-functions/types'; +import { IOT, ReaderTaskT } from '@craigmiller160/ts-functions/types'; import { authenticateWithAuthCode } from '../auth/AuthCodeAuthentication'; import { logout } from '../auth/Logout'; import { errorReaderTask } from '../../function/Route'; import { ExpressDependencies } from '../../express/ExpressDependencies'; import * as ReaderTaskEither from 'fp-ts/ReaderTaskEither'; +import * as IOEither from 'fp-ts/IOEither'; export const getAuthUser = (req: Request, res: Response): void => { const token = req.user as AccessToken; @@ -30,12 +30,12 @@ export const getAuthCodeLogin = ( req: Request, res: Response, next: NextFunction -): void => +): IOT => pipe( prepareAuthCodeLogin(req), - Either.fold( - (ex) => next(ex), - (url) => { + IOEither.fold( + (ex) => () => next(ex), + (url) => () => { const response: AuthCodeLoginResponse = { url }; diff --git a/src/services/routes/TradierService.ts b/src/services/routes/TradierService.ts index 60b6d1a..e997f0d 100644 --- a/src/services/routes/TradierService.ts +++ b/src/services/routes/TradierService.ts @@ -2,7 +2,13 @@ import { NextFunction, Request, Response } from 'express'; import { isAxiosError, restClient } from '../RestClient'; import { getRequiredValues } from '../../function/Values'; import { flow, identity, pipe } from 'fp-ts/function'; -import { TaskT, TaskTryT, TryT } from '@craigmiller160/ts-functions/types'; +import { + IOT, + IOTryT, + OptionT, + TaskT, + TaskTryT +} from '@craigmiller160/ts-functions/types'; import { TaskTry } from '@craigmiller160/ts-functions'; import qs from 'qs'; import { match, when } from 'ts-pattern'; @@ -13,12 +19,17 @@ import { AxiosError } from 'axios'; import { TradierError } from '../../error/TradierError'; import * as Option from 'fp-ts/Option'; import * as Json from '@craigmiller160/ts-functions/Json'; +import * as Process from '@craigmiller160/ts-functions/Process'; +import * as IO from 'fp-ts/IO'; -const getTradierEnv = (): TryT> => - getRequiredValues([ - process.env.TRADIER_BASE_URL, - process.env.TRADIER_API_KEY - ]); +const getTradierEnv = (): IOTryT> => { + const env: ReadonlyArray>> = [ + Process.envLookupO('TRADIER_BASE_URL'), + Process.envLookupO('TRADIER_API_KEY') + ]; + + return pipe(IO.sequenceArray(env), IO.map(getRequiredValues)); +}; const isNotEmpty = (text: string) => text.length > 0; @@ -91,7 +102,7 @@ export const queryTradier = ( ): TaskT => pipe( getTradierEnv(), - TaskEither.fromEither, + TaskEither.fromIOEither, TaskEither.chain(([baseUrl, apiKey]) => sendTradierRequest(baseUrl, apiKey, req.path, req.query) ), diff --git a/test/express/auth/secure.test.ts b/test/express/auth/secure.test.ts index f0b510c..c30f868 100644 --- a/test/express/auth/secure.test.ts +++ b/test/express/auth/secure.test.ts @@ -82,7 +82,7 @@ describe('TokenValidation', () => { process.env.COOKIE_MAX_AGE_SECS = '8600'; process.env.COOKIE_PATH = '/cookie-path'; const token = createAccessToken(fullTestServer.keyPair.privateKey); - const tokenCookie = pipe(createTokenCookie(token), Try.getOrThrow); + const tokenCookie = pipe(createTokenCookie(token)(), Try.getOrThrow); const res = await request(fullTestServer.expressServer.server) .get('/portfolios') .timeout(2000) @@ -198,7 +198,7 @@ describe('TokenValidation', () => { const token = createAccessToken(fullTestServer.keyPair.privateKey, { expiresIn: '-10m' }); - const tokenCookie = Try.getOrThrow(createTokenCookie(token)); + const tokenCookie = Try.getOrThrow(createTokenCookie(token)()); await request(fullTestServer.expressServer.server) .get('/portfolios') .timeout(2000) @@ -233,7 +233,7 @@ describe('TokenValidation', () => { const token = createAccessToken(fullTestServer.keyPair.privateKey, { expiresIn: '-10m' }); - const tokenCookie = Try.getOrThrow(createTokenCookie(token)); + const tokenCookie = Try.getOrThrow(createTokenCookie(token)()); const res = await request(fullTestServer.expressServer.server) .get('/portfolios') .timeout(2000) @@ -251,7 +251,7 @@ describe('TokenValidation', () => { }) ); - const newTokenCookie = Try.getOrThrow(createTokenCookie(newToken)); + const newTokenCookie = Try.getOrThrow(createTokenCookie(newToken)()); const returnedTokenCookie = ( res.headers['set-cookie'] as string[] ).find((cookie) => cookie.startsWith('cookieName')); @@ -273,7 +273,7 @@ describe('TokenValidation', () => { const token = createAccessToken(fullTestServer.keyPair.privateKey, { expiresIn: '-10m' }); - const tokenCookie = Try.getOrThrow(createTokenCookie(token)); + const tokenCookie = Try.getOrThrow(createTokenCookie(token)()); await request(fullTestServer.expressServer.server) .get('/portfolios') .timeout(2000) @@ -302,7 +302,7 @@ describe('TokenValidation', () => { const token = createAccessToken(fullTestServer.keyPair.privateKey, { expiresIn: '-10m' }); - const tokenCookie = Try.getOrThrow(createTokenCookie(token)); + const tokenCookie = Try.getOrThrow(createTokenCookie(token)()); await request(fullTestServer.expressServer.server) .get('/portfolios') .timeout(2000) diff --git a/test/express/routes/oauth.test.ts b/test/express/routes/oauth.test.ts index d10f6f3..f9f3948 100644 --- a/test/express/routes/oauth.test.ts +++ b/test/express/routes/oauth.test.ts @@ -202,7 +202,7 @@ describe('oauth routes', () => { .post('/oauth/authcode/login') .set('Origin', 'origin') .timeout(2000) - .expect(401); + .expect(500); }); }); @@ -274,7 +274,7 @@ describe('oauth routes', () => { .get(`/oauth/authcode/code?code=${code}&state=${state}`) .set('Cookie', sessionCookie) .timeout(2000) - .expect(401); + .expect(500); const count = await AppRefreshTokenModel.count().exec(); expect(count).toEqual(1); diff --git a/test/services/auth/TokenKey.test.ts b/test/services/auth/TokenKey.test.ts index e0f5a67..e46df88 100644 --- a/test/services/auth/TokenKey.test.ts +++ b/test/services/auth/TokenKey.test.ts @@ -53,7 +53,7 @@ describe('TokenKey', () => { const result = await loadTokenKey()(); expect(result).toEqualLeft( - new Error('Auth Server Host variable is not available') + new Error('Env not found: AUTH_SERVER_HOST') ); expect(jwkToPemMock).not.toHaveBeenCalled(); diff --git a/yarn.lock b/yarn.lock index 1536e19..29abf8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -343,9 +343,9 @@ prettier "^2.5.1" "@craigmiller160/ts-functions@^1.1.0-beta": - version "1.1.0-beta.5" - resolved "https://craigmiller160.ddns.net:30003/repository/npm-group/@craigmiller160/ts-functions/-/ts-functions-1.1.0-beta.5.tgz#e6714ff57977b316c79e7365af29b7cef61a5813" - integrity sha512-RN9116KSGCH3x8XMU363gBN7M/g3EtHw4r0NZqDBSFqX+wDu2L/G9GPpGlUoPU7jGyfcyUeGCHDLCas+cYu9Ag== + version "1.1.0-beta.8" + resolved "https://craigmiller160.ddns.net:30003/repository/npm-group/@craigmiller160/ts-functions/-/ts-functions-1.1.0-beta.8.tgz#4d76f4a67bcd4a66121d5724d570c8819f6d0b6b" + integrity sha512-Csmwn1/7kvkNQth+s5R/3Q6QEU2F5IQYfsTCPlS2OvHt+pdwf9HOOGf3+ALJhysuYskq2gIdr0F5FgJyRmunTQ== "@cspotcode/source-map-consumer@0.8.0": version "0.8.0"