From 892d49ab0a682d64294fed553ec79a8849d5310f Mon Sep 17 00:00:00 2001 From: maany Date: Mon, 10 Jul 2023 14:50:53 +0200 Subject: [PATCH] sdk: add BaseMultiCallStreamUseCase #226 --- .github/workflows/sdk.yml | 26 ++++++ jest.config.js | 1 + package.json | 1 + .../list-dids-usecase-pipeline-element.ts | 29 +++++++ src/lib/core/use-case/list-dids-usecase.ts | 1 + src/lib/core/use-case/list-dids-usecase2.ts | 77 +++++++++++++++++ src/lib/sdk/primary-ports.ts | 23 ++++- src/lib/sdk/usecase-stream-element.ts | 85 +++++++++++++++++++ src/lib/sdk/usecase.ts | 69 +++++++++++++++ test/sdk/jest.sdk.config.js | 30 +++++++ test/sdk/jest.sdk.setup.ts | 5 ++ test/sdk/multicall-usecase.test.ts | 0 12 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sdk.yml create mode 100644 src/lib/core/use-case/list-dids-usecase-pipeline-element.ts create mode 100644 src/lib/core/use-case/list-dids-usecase2.ts create mode 100644 src/lib/sdk/usecase-stream-element.ts create mode 100644 test/sdk/jest.sdk.config.js create mode 100644 test/sdk/jest.sdk.setup.ts create mode 100644 test/sdk/multicall-usecase.test.ts diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml new file mode 100644 index 00000000..d02cbbeb --- /dev/null +++ b/.github/workflows/sdk.yml @@ -0,0 +1,26 @@ +name: SDK Tests +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + test: + name: Test WebUI SDK + strategy: + matrix: + node-version: [16.14.0, 18.5.0] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 18.x + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: npm install + - name: Run SDK Tests + run: npm run test:sdk + diff --git a/jest.config.js b/jest.config.js index 54797cd4..b86e2e3e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,5 +3,6 @@ module.exports = { '/test/api/jest.api.config.js', '/test/component/jest.component.config.js', '/test/gateway/jest.gateway.config.js', + '/test/sdk/jest.sdk.config.js', ], } \ No newline at end of file diff --git a/package.json b/package.json index 4a77044e..0e2b638f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:api": "jest --testPathPattern=test/api --projects test/api/jest.api.config.js", "test:component": "jest --testPathPattern=test/component --projects test/component/jest.component.config.js", "test:gateway": "jest --testPathPattern=test/gateway --projects test/gateway/jest.gateway.config.js", + "test:sdk": "jest --testPathPattern=test/sdk --projects test/sdk/jest.sdk.config.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "build-tailwind": "tailwindcss -i src/component-library/tailwind.css -o src/component-library/outputtailwind.css" diff --git a/src/lib/core/use-case/list-dids-usecase-pipeline-element.ts b/src/lib/core/use-case/list-dids-usecase-pipeline-element.ts new file mode 100644 index 00000000..5d10290f --- /dev/null +++ b/src/lib/core/use-case/list-dids-usecase-pipeline-element.ts @@ -0,0 +1,29 @@ +import { BaseMultiCallUseCasePipelineElement } from "@/lib/sdk/usecase-stream-element"; +import { inject } from "inversify"; +import { ListDIDDTO } from "../dto/did-dto"; +import { ListDIDsError, ListDIDsRequest, ListDIDsResponse } from "../usecase-models/list-dids-usecase-models"; + +class ListDIDsGetListOfDIDs extends BaseMultiCallUseCasePipelineElement< + ListDIDsRequest, + ListDIDsResponse, + ListDIDsError, + ListDIDDTO +>{ + constructor( + @inject('didGateway') private didGateway: DIDGatewayOutputPort, + ) { + super(requestModel, responseModel) + } + makeGatewayRequest(requestModel: ListDIDsRequest): Promise { + throw new Error("Method not implemented."); + } + processDTO(dto: ListDIDDTO): { data: ListDIDsResponse | ListDIDsError; status: "success" | "error"; } { + throw new Error("Method not implemented."); + } + handleGatewayError(error: ListDIDDTO): ListDIDsError { + throw new Error("Method not implemented."); + } + transformResponseModel(responseModel: ListDIDsResponse, dto: ListDIDDTO): ListDIDsResponse { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/lib/core/use-case/list-dids-usecase.ts b/src/lib/core/use-case/list-dids-usecase.ts index bd011404..7ec5d993 100644 --- a/src/lib/core/use-case/list-dids-usecase.ts +++ b/src/lib/core/use-case/list-dids-usecase.ts @@ -64,6 +64,7 @@ class ListDIDsUseCase extends BaseStreamingUseCase implements ListDIDsInputPort { + validateFinalResponseModel(responseModel: ListDIDsResponse): { isValid: boolean; errorModel?: ListDIDsError | undefined; } { + throw new Error("Method not implemented."); + } + constructor( + protected presenter: ListDIDsOutputPort, + private didGateway: DIDGatewayOutputPort, + ) { + super(presenter) + this.didGateway = didGateway; + } + + validateRequestModel(requestModel: AuthenticatedRequestModel): ListDIDsError | undefined { + let scope: string; + let name: string; + try{ + let didComponents = parseDIDString(requestModel.query); + scope = didComponents.scope; + name = didComponents.name; + } catch (error: any) { + return { + status: 'error', + error: 'Invalid DID Query', + message: (error as Error).message, + } as ListDIDsError + } + } + + // async makeGatewayRequest(requestModel: AuthenticatedRequestModel): Promise { + // const { scope, name } = parseDIDString(requestModel.query); + // const listDIDDTO: ListDIDDTO = await this.didGateway.listDIDs(requestModel.rucioAuthToken, scope, name, requestModel.type); + // return listDIDDTO; + // } + + // handleGatewayError(error: ListDIDDTO): ListDIDsError { + // let errorType = 'Unknown Error' + // if(error.error === 'Invalid Auth Token') { + // errorType = 'Invalid Request' + // } + // else if(error.error !== 'Unknown Error') { + // errorType = 'Invalid DID Query' + // } + + // return { + // error: errorType, + // message: `${error.error}: ${error.message}`, + // } as ListDIDsError + // } + + // processStreamedData(dto: DID): { data: ListDIDsResponse | ListDIDsError; status: "success" | "error"; } { + // const responseModel: ListDIDsResponse = { + // status: 'success', + // name: dto.name, + // scope: dto.scope, + // did_type: dto.did_type, + // length: 0, + // bytes: 0, + // } + // return { + // data: responseModel, + // status: 'success', + // } + // } +} + +export default ListDIDsUseCase2; \ No newline at end of file diff --git a/src/lib/sdk/primary-ports.ts b/src/lib/sdk/primary-ports.ts index b04f421a..78b19056 100644 --- a/src/lib/sdk/primary-ports.ts +++ b/src/lib/sdk/primary-ports.ts @@ -1,5 +1,7 @@ import { PassThrough, Transform } from 'stream' -import { AuthenticatedRequestModel } from './usecase-models' +import { BaseDTO } from './dto' +import { AuthenticatedRequestModel, BaseErrorResponseModel, BaseResponseModel } from './usecase-models' +import { BaseMultiCallUseCasePipelineElement } from './usecase-stream-element' import { TWebResponse } from './web' /** @@ -27,6 +29,25 @@ export interface BaseStreamableInputPort execute(requestModel: AuthenticatedRequestModel): Promise } +/** + * A base interface for multi-call streamable input ports. A streamable input port provides a pipeline of {@link BaseMultiCallUseCasePipelineElement} elements that are used to process the request model. + * These pipeline elements recieve the request model and the latest response model and return a new response model. + * The pipeline elements are executed in the order they are provided. + * @typeparam AuthenticatedRequestModel The type of the authenticated request model for the input port. + * @typeparam TResponseModel The type of the response model for the input port. + * @typeparam TErrorModel The type of the error model for the input port. + */ +export interface BaseMultiCallStreamableInputPort{ + /** + * Validates the final response model. + * @param responseModel The response model to validate. + */ + validateFinalResponseModel(responseModel: TResponseModel): { + isValid: boolean, + errorModel?: TErrorModel + } +} + /** * A base interface for output ports. * @typeparam TResponseModel The type of the response model for the output port. diff --git a/src/lib/sdk/usecase-stream-element.ts b/src/lib/sdk/usecase-stream-element.ts new file mode 100644 index 00000000..ea60ebbd --- /dev/null +++ b/src/lib/sdk/usecase-stream-element.ts @@ -0,0 +1,85 @@ +import { Transform } from 'stream'; +import { BaseDTO } from './dto'; +import { BaseErrorResponseModel, BaseResponseModel } from './usecase-models'; + +export abstract class BaseMultiCallUseCasePipelineElement extends Transform { + protected requestModel: TRequestModel + protected responseModel: TResponseModel + + constructor(requestModel: TRequestModel, responseModel: TResponseModel) { + super({ objectMode: true }); + this.requestModel = requestModel + this.responseModel = responseModel + } + + /** + * Makes a gateway request with the given request model. + * @param requestModel The request model to send to the gateway. + * @returns A promise that resolves with the DTO returned by the gateway. + */ + abstract makeGatewayRequest(requestModel: TRequestModel): Promise + + /** + * Handles the DTO returned by the gateway. + * This method is called when the gateway returns a DTO with a status of `success`. + * @param dto The DTO returned by the gateway. + * @returns An object that contains the response or error model and the status of processing the DTO. + */ + abstract processDTO(dto: TDTO): { + data: TResponseModel | TErrorModel + status: 'success' | 'error' + } + + /** + * Handles a gateway error by converting it to an error model. + * This method is called when the gateway returns a DTO with a status of `error`. + * @param error The DTO returned by the gateway. + * @returns An error model that represents the gateway error. + */ + abstract handleGatewayError(error: TDTO): TErrorModel + + /** + * Modifies the response model of the use case with the data returned by the gateway. + * @param responseModel The response model to transform. + * @param dto The valid DTO returned by the gateway. + */ + abstract transformResponseModel(responseModel: TResponseModel, dto: TDTO): TResponseModel + + /** + * Processes the response and DTO returned by the gateway. + * @param response The DTO returned by the gateway. + * @returns The ResponseModel or ErrorModel returned, based on processing of the DTO. + */ + processGatewayResponse(response: TDTO): TResponseModel | TErrorModel { + if (response.status === 'success') { + const { status, data } = this.processDTO(response) + if( status === 'success') { + return data as TResponseModel + } + return data as TErrorModel + } + const errorModel: TErrorModel = this.handleGatewayError(response) + return errorModel + } + + async exceute(): Promise { + const dto = await this.makeGatewayRequest(this.requestModel) + const data: TResponseModel | TErrorModel = this.processGatewayResponse(dto) + if(data.status === 'error') { + return Promise.reject(data) + } + const transformedResponseModel = this.transformResponseModel(this.responseModel, dto) + return Promise.resolve(transformedResponseModel) + } + + _transform(chunk: TResponseModel, encoding: BufferEncoding, callback: (error?: Error, data?: TResponseModel | TErrorModel) => void): void { + this.responseModel = chunk + this.exceute().then((data) => { + this.push(data) + callback(undefined, data) // TODO check if data is not pushed twice + }).catch((error) => { + this.emit('error', error) + callback(error) + }) + } +} diff --git a/src/lib/sdk/usecase.ts b/src/lib/sdk/usecase.ts index 2b9839bc..230ec874 100644 --- a/src/lib/sdk/usecase.ts +++ b/src/lib/sdk/usecase.ts @@ -1,6 +1,7 @@ import { BaseAuthenticatedInputPort, BaseInputPort, + BaseMultiCallStreamableInputPort, BaseOutputPort, BaseStreamableInputPort, BaseStreamingOutputPort, @@ -8,6 +9,8 @@ import { import { AuthenticatedRequestModel, BaseErrorResponseModel, BaseResponseModel } from './usecase-models' import { Transform, TransformCallback } from 'stream' import { BaseDTO, BaseStreamableDTO } from './dto' +import { BaseMultiCallUseCasePipelineElement } from './usecase-stream-element' +import { BaseStreamingPresenter } from './presenter' /** * A type that represents a simple use case that does not require authentication. @@ -235,4 +238,70 @@ export abstract class BaseStreamingUseCase +extends Transform implements BaseMultiCallStreamableInputPort, TResponseModel, TErrorModel> { + protected presenter: BaseStreamingPresenter + protected pipelineElements: BaseMultiCallUseCasePipelineElement, TResponseModel, TErrorModel, any>[] = [] + constructor( + presenter: BaseStreamingPresenter, + pipelineElements: BaseMultiCallUseCasePipelineElement, TResponseModel, TErrorModel, any>[] + ) { + super({ objectMode: true }) + this.presenter = presenter + this.pipelineElements = pipelineElements + } + + abstract validateRequestModel(requestModel: TRequestModel): TErrorModel | undefined; + + setupPipeline(pipelineElements: BaseMultiCallUseCasePipelineElement, TResponseModel, TErrorModel, any>[]): void { + // loop over pipeline elements and pipe them together. Pipe the last element to this object + // for validation and pipe this to presenter + for (let i = 0; i < pipelineElements.length; i++) { + const pipelineElement = pipelineElements[i] + if(i === pipelineElements.length - 1) { + pipelineElement.on('error', (error) => this.handleError(error)) + .pipe(this.presenter) + } + else { + const previousPipelineElement = pipelineElements[i - 1] + previousPipelineElement.pipe(pipelineElement) + } + } + } + abstract validateFinalResponseModel(responseModel: TResponseModel): { isValid: boolean; errorModel?: TErrorModel | undefined } + abstract handleError(error: Error): void + + async execute(requestModel: AuthenticatedRequestModel): Promise { + const validationError = this.validateRequestModel(requestModel) + if (validationError) { + this.presenter.presentError(validationError) + } + try { + this.setupPipeline(this.pipelineElements) + } catch (error: any) { + // TODO here we catch any critical errors that occur during pipeline setup or execution + const errorModel = error as TErrorModel + + this.presenter.presentError(error) + } + } + + _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void { + const data = JSON.parse(chunk) + const {requestModel, responseModel, errorModel} = data + const { isValid, errorModel: finalErrorModel } = this.validateFinalResponseModel(responseModel) + if(isValid) { + this.push(JSON.stringify(responseModel)) + } + else { + this.emit('error', JSON.stringify(finalErrorModel)) + } + callback() + } } \ No newline at end of file diff --git a/test/sdk/jest.sdk.config.js b/test/sdk/jest.sdk.config.js new file mode 100644 index 00000000..eb15e15b --- /dev/null +++ b/test/sdk/jest.sdk.config.js @@ -0,0 +1,30 @@ +const nextJest = require('next/jest') + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}) + +// Add any custom config to be passed to Jest +/** @type {import('jest').Config} */ +const customJestConfig = { + displayName: 'sdk', + rootDir: '../../', + // Add more setup options before each test is run + setupFilesAfterEnv: ['/test/sdk/jest.sdk.setup.ts'], + // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work + moduleDirectories: ['node_modules', '/'], + + // If you're using [Module Path Aliases](https://nextjs.org/docs/advanced-features/module-path-aliases), + // you will have to add the moduleNameMapper in order for jest to resolve your absolute paths. + // The paths have to be matching with the paths option within the compilerOptions in the tsconfig.json + // For example: + moduleNameMapper: { + '@/(.*)$': '/src/$1', + }, + testEnvironment: 'jest-environment-jsdom', + +} + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig) diff --git a/test/sdk/jest.sdk.setup.ts b/test/sdk/jest.sdk.setup.ts new file mode 100644 index 00000000..c7d8f364 --- /dev/null +++ b/test/sdk/jest.sdk.setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/extend-expect' +import "reflect-metadata" +import fetchMock from 'jest-fetch-mock' +import "@inrupt/jest-jsdom-polyfills" +fetchMock.enableMocks() diff --git a/test/sdk/multicall-usecase.test.ts b/test/sdk/multicall-usecase.test.ts new file mode 100644 index 00000000..e69de29b