From 39745ccf22a8c41751eacbec07318580ef8009cc Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 27 Nov 2020 13:42:18 +0100 Subject: [PATCH] feat: create PodHttpHandler with default interfaces --- .eslintrc.js | 3 +- src/pods/PodManager.ts | 15 ++++ src/pods/PodManagerHttpHandler.ts | 69 +++++++++++++++++ src/pods/agent/Agent.ts | 9 +++ src/pods/agent/AgentParser.ts | 8 ++ test/unit/pods/PodManagerHttpHandler.test.ts | 80 ++++++++++++++++++++ 6 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/pods/PodManager.ts create mode 100644 src/pods/PodManagerHttpHandler.ts create mode 100644 src/pods/agent/Agent.ts create mode 100644 src/pods/agent/AgentParser.ts create mode 100644 test/unit/pods/PodManagerHttpHandler.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index ad392f6e6..f9752ad7d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,12 +27,12 @@ module.exports = { } }, rules: { + '@typescript-eslint/consistent-type-definitions': 'off', // there are valid typing reasons to have one or the other '@typescript-eslint/lines-between-class-members': [ 'error', { exceptAfterSingleLine: true }], '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-invalid-void-type': 'off', // breaks with default void in Asynchandler 2nd generic '@typescript-eslint/no-unnecessary-condition': 'off', // problems with optional parameters '@typescript-eslint/space-before-function-paren': [ 'error', 'never' ], - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], '@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unified-signatures': 'off', 'class-methods-use-this': 'off', // conflicts with functions from interfaces that sometimes don't require `this` @@ -82,6 +82,7 @@ module.exports = { ], // Import + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], 'sort-imports': 'off', // Disabled in favor of eslint-plugin-import 'import/order': ['error', { alphabetize: { diff --git a/src/pods/PodManager.ts b/src/pods/PodManager.ts new file mode 100644 index 000000000..afbd37e00 --- /dev/null +++ b/src/pods/PodManager.ts @@ -0,0 +1,15 @@ +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import type { Agent } from './agent/Agent'; + +/** + * Covers all functions related to pod management. + * In the future this should also include delete, and potentially recovery functions. + */ +export interface PodManager { + /** + * Creates a pod for the given agent data. + * @param agent - Data of the agent that needs a pod. + * @returns {@link ResourceIdentifier} of the newly created pod. + */ + createPod: (agent: Agent) => Promise; +} diff --git a/src/pods/PodManagerHttpHandler.ts b/src/pods/PodManagerHttpHandler.ts new file mode 100644 index 000000000..213316d2f --- /dev/null +++ b/src/pods/PodManagerHttpHandler.ts @@ -0,0 +1,69 @@ +import type { RequestParser } from '../ldp/http/RequestParser'; +import { CreatedResponseDescription } from '../ldp/http/response/CreatedResponseDescription'; +import type { ResponseWriter } from '../ldp/http/ResponseWriter'; +import { HttpHandler } from '../server/HttpHandler'; +import type { HttpRequest } from '../server/HttpRequest'; +import type { HttpResponse } from '../server/HttpResponse'; +import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; +import { InternalServerError } from '../util/errors/InternalServerError'; +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import type { AgentParser } from './agent/AgentParser'; +import type { PodManager } from './PodManager'; + +export interface PodHttpHandlerArgs { + /** The path on which this handler should intercept requests. Should start with a slash. */ + requestPath: string; + /** Parses the incoming request. */ + requestParser: RequestParser; + /** Parses the data stream to an Agent. */ + agentParser: AgentParser; + /** Handles the pod management. */ + manager: PodManager; + /** Writes the outgoing response. */ + responseWriter: ResponseWriter; +} + +/** + * An HTTP handler that listens to requests to a specific path for pod related requests. + * Handles everything related to pod management from input request to output response. + */ +export class PodManagerHttpHandler extends HttpHandler { + private readonly requestPath!: string; + private readonly requestParser!: RequestParser; + private readonly agentParser!: AgentParser; + private readonly manager!: PodManager; + private readonly responseWriter!: ResponseWriter; + + public constructor(args: PodHttpHandlerArgs) { + super(); + Object.assign(this, args); + } + + public async canHandle({ request }: { request: HttpRequest }): Promise { + if (request.url !== this.requestPath) { + throw new NotImplementedHttpError(`Only requests to ${this.requestPath} are accepted`); + } + } + + public async handle({ request, response }: { request: HttpRequest; response: HttpResponse }): Promise { + try { + if (request.method !== 'POST') { + throw new NotImplementedHttpError('Only POST requests are supported'); + } + const op = await this.requestParser.handleSafe(request); + if (!op.body) { + throw new BadRequestHttpError('A body is required to create a pod'); + } + const agent = await this.agentParser.handleSafe(op.body); + const id = await this.manager.createPod(agent); + + await this.responseWriter.handleSafe({ response, result: new CreatedResponseDescription(id) }); + } catch (error: unknown) { + if (error instanceof Error) { + await this.responseWriter.handleSafe({ response, result: error }); + } else { + await this.responseWriter.handleSafe({ response, result: new InternalServerError('Unexpected error') }); + } + } + } +} diff --git a/src/pods/agent/Agent.ts b/src/pods/agent/Agent.ts new file mode 100644 index 000000000..491e5c31a --- /dev/null +++ b/src/pods/agent/Agent.ts @@ -0,0 +1,9 @@ +/** + * Agent metadata related to pod generation. + */ +export type Agent = { + login: string; + webId: string; + name?: string; + email?: string; +}; diff --git a/src/pods/agent/AgentParser.ts b/src/pods/agent/AgentParser.ts new file mode 100644 index 000000000..f1b3c64d3 --- /dev/null +++ b/src/pods/agent/AgentParser.ts @@ -0,0 +1,8 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import { AsyncHandler } from '../../util/AsyncHandler'; +import type { Agent } from './Agent'; + +/** + * Parser that generates a {@link Agent} from the data in the given {@link Representation}. + */ +export abstract class AgentParser extends AsyncHandler { } diff --git a/test/unit/pods/PodManagerHttpHandler.test.ts b/test/unit/pods/PodManagerHttpHandler.test.ts new file mode 100644 index 000000000..d32c01375 --- /dev/null +++ b/test/unit/pods/PodManagerHttpHandler.test.ts @@ -0,0 +1,80 @@ +import type { RequestParser } from '../../../src/ldp/http/RequestParser'; +import { CreatedResponseDescription } from '../../../src/ldp/http/response/CreatedResponseDescription'; +import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; +import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; +import type { AgentParser } from '../../../src/pods/agent/AgentParser'; +import type { PodManager } from '../../../src/pods/PodManager'; +import { PodManagerHttpHandler } from '../../../src/pods/PodManagerHttpHandler'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; +import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError'; +import { InternalServerError } from '../../../src/util/errors/InternalServerError'; +import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; +import { StaticAsyncHandler } from '../../util/StaticAsyncHandler'; + +describe('A PodManagerHttpHandler', (): void => { + const requestPath = '/pods'; + let requestParser: RequestParser; + let agentParser: AgentParser; + let manager: PodManager; + let responseWriter: ResponseWriter; + let handler: PodManagerHttpHandler; + + beforeEach(async(): Promise => { + requestParser = { handleSafe: jest.fn((): any => 'requestParser') } as any; + agentParser = new StaticAsyncHandler(true, 'agentParser' as any); + manager = { + createPod: jest.fn(), + }; + responseWriter = { handleSafe: jest.fn((): any => 'response') } as any; + handler = new PodManagerHttpHandler({ requestPath, requestParser, agentParser, manager, responseWriter }); + }); + + it('only supports requests to /pods.', async(): Promise => { + const call = handler.canHandle({ request: { url: '/notPods' } as HttpRequest }); + await expect(call).rejects.toThrow('Only requests to /pods are accepted'); + await expect(call).rejects.toThrow(NotImplementedHttpError); + await expect(handler.canHandle({ request: { url: '/pods' } as HttpRequest })).resolves.toBeUndefined(); + }); + + it('writes an error if the request was no POST.', async(): Promise => { + const response = {} as HttpResponse; + await expect(handler.handle({ request: { method: 'GET' } as HttpRequest, response })).resolves.toBeUndefined(); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const mockCall = (responseWriter.handleSafe as jest.Mock).mock.calls[0][0]; + expect(mockCall).toEqual({ response, result: expect.any(NotImplementedHttpError) }); + expect(mockCall.result.message).toBe('Only POST requests are supported'); + }); + + it('writes an error if there is no input body.', async(): Promise => { + const response = {} as HttpResponse; + await expect(handler.handle({ request: { method: 'POST' } as HttpRequest, response })).resolves.toBeUndefined(); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const mockCall = (responseWriter.handleSafe as jest.Mock).mock.calls[0][0]; + expect(mockCall).toEqual({ response, result: expect.any(BadRequestHttpError) }); + expect(mockCall.result.message).toBe('A body is required to create a pod'); + }); + + it('writes an internal error if a non-error was thrown.', async(): Promise => { + const response = {} as HttpResponse; + (requestParser.handleSafe as jest.Mock).mockImplementationOnce((): any => { + throw 'apple'; + }); + await expect(handler.handle({ request: { method: 'POST' } as HttpRequest, response })).resolves.toBeUndefined(); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + const mockCall = (responseWriter.handleSafe as jest.Mock).mock.calls[0][0]; + expect(mockCall).toEqual({ response, result: expect.any(InternalServerError) }); + expect(mockCall.result.message).toBe('Unexpected error'); + }); + + it('returns the id of the created pod on success.', async(): Promise => { + const response = {} as HttpResponse; + (manager.createPod as jest.Mock).mockImplementationOnce((): ResourceIdentifier => ({ path: '/pad/to/pod/' })); + (requestParser.handleSafe as jest.Mock).mockImplementationOnce((): any => ({ body: 'data' })); + await expect(handler.handle({ request: { method: 'POST' } as HttpRequest, response })).resolves.toBeUndefined(); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith( + { response, result: new CreatedResponseDescription({ path: '/pad/to/pod/' }) }, + ); + }); +});