Skip to content

Commit

Permalink
Tng 1261 endpoint integration (#196)
Browse files Browse the repository at this point in the history
* feat(fsxa-api integration): offer utilities for rest-endpoint implementation backed by fsxa-api

Expose Parameter Validators and Wrappers with error handling to use in an endpoint implementation.
Refactor default Express integration to use those wrappers

* fix(integrationtests): pass integrationtests

Minor Adjustments to pass integrationtests

* minor test adjustment

* docs(documentation): add Short Readme about integration and annotate exposed functions

* expose utils
  • Loading branch information
neo-reply-lukas authored Nov 29, 2023
1 parent 17c067f commit beb4a1a
Show file tree
Hide file tree
Showing 9 changed files with 654 additions and 104 deletions.
16 changes: 16 additions & 0 deletions docs/Endoint Integration Utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Endpoint Integration Utilities

A standard use-case for the javascript-content-api is to integrate it into some RESTful API or other API that exposes Endpoints on the web.

To facilitate that usecase, [validation utilities](../src/integrations/parameterValidation.ts) and [wrappers](../src/integrations/endpointIntegrationWrapper.ts.ts) for different functionalities like _fetchByFilter_ or _fetchNavigation_ are provided.

### Validation Utilities

The Validation Utilities offer a simple way to test for required Paramters before passing them to the fsxa-api. They do not check the parameters semantically, as this is up to the fsxa-api implementation.

### Integration Wrappers

The Integration Wrappers are meant to be used by a webserver (like express) to safely pass parameters to the fsxa-api with error handling taken care of.
They return an Object containing the result of the Operation, with indicators of sensible return Codes and Error Messages on Errors.

Evaluating those Results and sending an appropriate response is quite easy and up to the user of the utilities. [See the reference Implementation using Express](../src/integrations/express.ts).
20 changes: 6 additions & 14 deletions integrationtests/expressIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,9 @@ import express, { Express } from 'express'
import cors from 'cors'
import { Server } from 'http'
import { CaasTestingClient } from './utils'
import {
FSXAApiErrors,
FSXAContentMode,
FSXARemoteApi,
LogLevel,
HttpStatus,
} from '../src'
import { FSXAApiErrors, FSXAContentMode, FSXARemoteApi, LogLevel } from '../src'
import Faker from 'faker'
import {
ExpressRouterIntegrationErrors,
default as expressIntegration,
} from '../src/integrations/express'
import expressIntegration from '../src/integrations/express'
import {
createDataset,
createDatasetReference,
Expand All @@ -25,6 +16,7 @@ import {
Logger,
QueryBuilder,
} from '../src/modules'
import { PARAM_VALIDATION_ERROR_TO_MESSAGE } from '../src/integrations/endpointIntegrationWrapper'

dotenv.config({ path: './integrationtests/.env' })
const {
Expand Down Expand Up @@ -178,7 +170,7 @@ describe('express integration', () => {
expect(res.status).toBe(400)
expect(json).toHaveProperty('error')
expect(json.error).toContain(
ExpressRouterIntegrationErrors.MISSING_LOCALE
PARAM_VALIDATION_ERROR_TO_MESSAGE.MISSING_LOCALE
)
})
it('should return an error 401 if the remote api responds 401', async () => {
Expand Down Expand Up @@ -224,7 +216,7 @@ describe('express integration', () => {
expect(res.status).toBe(400)
expect(json).toHaveProperty('error')
expect(json.error).toContain(
ExpressRouterIntegrationErrors.MISSING_LOCALE
PARAM_VALIDATION_ERROR_TO_MESSAGE.MISSING_LOCALE
)
})
it('should return an error 404 if the remote api responds 404', async () => {
Expand Down Expand Up @@ -316,7 +308,7 @@ describe('express integration', () => {
expect(res.status).toBe(400)
expect(json).toHaveProperty('error')
expect(json.error).toContain(
ExpressRouterIntegrationErrors.MISSING_LOCALE
PARAM_VALIDATION_ERROR_TO_MESSAGE.MISSING_LOCALE
)
})
it.skip('should return an error 404 if the remote api responds 404', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ export * from './helpers/misc'
export * from './exceptions'
import * as ROUTES from './routes'
export { ROUTES }
export * from './integrations/parameterValidation'
export * from './integrations/endpointIntegrationWrapper'
277 changes: 277 additions & 0 deletions src/integrations/endpointIntegrationWrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { HttpError } from '../exceptions'
import { FSXARemoteApi, Logger } from '../modules'
import {
FetchByFilterBody,
FetchElementRouteBody,
FetchNavigationRouteBody,
FetchProjectPropertiesBody,
} from '../routes'
import {
ParamValidationError,
validateParamsFetchElement,
validateParamsFetchByFilter,
validateParamsFetchNavigation,
validateParamsFetchProjectProperties,
} from './parameterValidation'
import {
FetchResponse,
NavigationData,
NormalizedProjectPropertyResponse,
ProjectProperties,
} from '../types'

export type FetchWrapperResult<T = any> =
| FetchWrapperSuccess<T>
| FetchWrapperError

type FetchWrapperSuccess<T = any> = {
success: true
data: T
}

type FetchWrapperError = {
success: false
error: {
statusCode: number
message: string
}
originalError: Error | ParamValidationError
}

/**
* Useful Error Messages for Validation Errors.
* Can be used as an Error Message to send to the client
*/
export const PARAM_VALIDATION_ERROR_TO_MESSAGE: Record<
ParamValidationError['reason'],
string
> = {
MISSING_ID:
'Please specify a locale in the body through: e.g. "locale": "de_DE" ',
MISSING_LOCALE: 'Please Provide an id in the body',
MISSING_PARAM_OBJECT:
"Please provide required params in the body. required: 'id', 'locale'",
MISSING_FILTERS: 'Please provide non-empty filters array',
} as const

/**
* Provides Wrapper Functions fot the given api.
* @param api FSXAApi to wrap
* @param loggerName Creates a logger with the given name
* @returns
*/
export const useEndpointIntegrationWrapper = (
api: FSXARemoteApi,
loggerName = 'Endpoint-Utils'
) => {
const logger = new Logger(api.logLevel, loggerName)

/**
* Fetches an Element by Id via {@link FSXARemoteApi}
* @param params
* @param logContext
* @returns A ResultWrapper, that either indicates success and contains the data, or indicates an Error and a Reason
*/
const fetchElementWrapper = async (
params: FetchElementRouteBody,
logContext: string = '[fetchElement]'
): Promise<FetchWrapperResult> => {
logger.debug(logContext, 'Called')

try {
const validationResult = validateParamsFetchElement(params)

if (!validationResult.valid) {
return handleValidationError(validationResult, params, logContext)
}

logger.debug(logContext, 'Params valid, fetching from Api')

const response = await api.fetchElement({
id: params.id,
locale: params.locale,
additionalParams: params.additionalParams,
remoteProject: params.remote,
filterContext: params.filterContext,
normalized: params.normalized,
})
logger.debug(logContext, 'fetch successful')

return {
success: true,
data: response,
}
} catch (error: unknown) {
return handleRuntimeError(error, logContext)
}
}

/**
* Fetches Navigation via {@link FSXARemoteApi}
* @param params
* @param logContext
* @returns A ResultWrapper, that either indicates success and contains the data, or indicates an Error and a Reason
*/
const fetchNavigationWrapper = async (
params: FetchNavigationRouteBody,
logContext: string = '[fetchNavigation]'
): Promise<FetchWrapperResult<NavigationData | null>> => {
logger.debug(logContext, 'Called')

try {
const validationResult = validateParamsFetchNavigation(params)

if (!validationResult.valid) {
return handleValidationError(validationResult, params, logContext)
}
logger.debug(logContext, 'Params valid, fetching from Api')

const response = await api.fetchNavigation({
initialPath: params.initialPath || '/',
locale: params.locale,
filterContext: params.filterContext,
})

logger.debug(logContext, 'fetch successful')
return {
success: true,
data: response,
}
} catch (error: unknown) {
return handleRuntimeError(error, logContext)
}
}

/**
* Fetches Elements by Filter via {@link FSXARemoteApi}
* @param params
* @param logContext
* @returns A ResultWrapper, that either indicates success and contains the data, or indicates an Error and a Reason
*/
const fetchByFilterWrapper = async (
params: FetchByFilterBody,
logContext: string = '[fetchByFilter]'
): Promise<FetchWrapperResult<FetchResponse>> => {
logger.debug(logContext, 'Called')

try {
const validationResult = validateParamsFetchByFilter(params)
if (!validationResult.valid) {
return handleValidationError(validationResult, params, logContext)
}

logger.debug(logContext, 'Params valid, fetching from Api')

const response = await api.fetchByFilter({
filters: params.filters || [],
locale: params.locale,
page: params.page ? params.page : undefined,
pagesize: params.pagesize ? params.pagesize : undefined,
sort: params.sort ? params.sort : [],
additionalParams: params.additionalParams || {},
remoteProject: params.remote ? params.remote : undefined,
filterContext: params.filterContext,
normalized: params?.normalized,
})
logger.debug(logContext, 'fetch successful')

return {
success: true,
data: response,
}
} catch (error: unknown) {
return handleRuntimeError(error, logContext)
}
}

/**
* Fetches ProjectProperties via {@link FSXARemoteApi}
* @param params
* @param logContext
* @returns A ResultWrapper, that either indicates success and contains the data, or indicates an Error and a Reason
*/
const fetchProjectPropertiesWrapper = async (
params: FetchProjectPropertiesBody,
logContext: string = '[fetchProjectProperties]'
): Promise<
FetchWrapperResult<
ProjectProperties | NormalizedProjectPropertyResponse | null
>
> => {
logger.debug(logContext, 'Called')

try {
const validationResult = validateParamsFetchProjectProperties(params)
if (!validationResult.valid) {
return handleValidationError(validationResult, params, logContext)
}

logger.debug(logContext, 'Params valid, fetching from Api')

const response = await api.fetchProjectProperties({
locale: params.locale,
additionalParams: params.additionalParams,
resolve: params.resolver,
filterContext: params.filterContext,
normalized: params.normalized,
})
logger.debug(logContext, 'fetch successful')

return {
success: true,
data: response,
}
} catch (error: unknown) {
return handleRuntimeError(error, logContext)
}
}

const handleValidationError = (
validationResult: ParamValidationError,
params: any,
logContext: string
): FetchWrapperError => {
logger.error(logContext, validationResult.reason, params)
return {
success: false,
error: {
statusCode: validationResult.statusCode,
message: PARAM_VALIDATION_ERROR_TO_MESSAGE[validationResult.reason],
},
originalError: validationResult,
}
}

function assertIsError(error: unknown): asserts error is Error {
// rethrow unknown error if its not an instance of error, since we do not know how to handle that
if (!(error instanceof Error)) {
throw error
}
}

const handleRuntimeError = (
error: unknown,
logContext: string
): FetchWrapperError => {
assertIsError(error)
logger.error(logContext, error)

const errorObj: FetchWrapperError['error'] = {
statusCode: (error as HttpError).statusCode || 500,
message: error.message,
}

return {
success: false,
error: errorObj,
originalError: error,
}
}

return {
fetchElementWrapper,
fetchNavigationWrapper,
fetchByFilterWrapper,
fetchProjectPropertiesWrapper,
}
}
8 changes: 5 additions & 3 deletions src/integrations/express.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
MapResponse,
} from '../modules'
import getExpressRouter, {
ExpressRouterIntegrationErrors,
UNKNOWN_ROUTE_ERROR,
getMappedFilters,
} from './express'
import {
Expand All @@ -29,6 +29,7 @@ import { FSXAContentMode } from '../enums'
import Faker from 'faker'
import faker from 'faker'
import { createDataset } from '../testutils'
import { PARAM_VALIDATION_ERROR_TO_MESSAGE } from './endpointIntegrationWrapper'

const PORT = 3125

Expand Down Expand Up @@ -213,7 +214,8 @@ describe('Express-Integration', () => {
await fetch(`http://localhost:${PORT}/navigation`, { method: 'POST' })
).json()
).toEqual({
error: ExpressRouterIntegrationErrors.MISSING_LOCALE,
error: PARAM_VALIDATION_ERROR_TO_MESSAGE.MISSING_LOCALE,
message: PARAM_VALIDATION_ERROR_TO_MESSAGE.MISSING_LOCALE,
})
})

Expand Down Expand Up @@ -356,7 +358,7 @@ describe('Express-Integration', () => {
await fetch(`http://localhost:${PORT}/foobar`, { method: 'POST' })
).json()
).toEqual({
error: ExpressRouterIntegrationErrors.UNKNOWN_ROUTE,
error: UNKNOWN_ROUTE_ERROR,
})
})
})
Expand Down
Loading

0 comments on commit beb4a1a

Please sign in to comment.