refactor: Separate middleware from Express.

This commit is contained in:
Ruben Verborgh 2020-11-29 23:24:29 +01:00
parent de079062be
commit 023ff80f48
18 changed files with 227 additions and 54 deletions

View File

@ -12,6 +12,7 @@
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/logging.json", "files-scs:config/presets/logging.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/setup.json", "files-scs:config/presets/setup.json",
"files-scs:config/presets/storage/backend/storage-memory.json", "files-scs:config/presets/storage/backend/storage-memory.json",

View File

@ -12,6 +12,7 @@
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/logging.json", "files-scs:config/presets/logging.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/setup.json", "files-scs:config/presets/setup.json",
"files-scs:config/presets/storage/backend/storage-filesystem.json", "files-scs:config/presets/storage/backend/storage-filesystem.json",

View File

@ -12,6 +12,7 @@
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/logging.json", "files-scs:config/presets/logging.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/setup.json", "files-scs:config/presets/setup.json",
"files-scs:config/presets/storage/backend/storage-filesystem.json", "files-scs:config/presets/storage/backend/storage-filesystem.json",

View File

@ -12,6 +12,7 @@
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/logging.json", "files-scs:config/presets/logging.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/setup.json", "files-scs:config/presets/setup.json",
"files-scs:config/presets/storage/backend/storage-filesystem.json", "files-scs:config/presets/storage/backend/storage-filesystem.json",

View File

@ -12,6 +12,7 @@
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/logging.json", "files-scs:config/presets/logging.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/setup.json", "files-scs:config/presets/setup.json",
"files-scs:config/presets/storage/backend/storage-sparql-endpoint.json", "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",

View File

@ -17,6 +17,18 @@
"ExpressHttpServerFactory:_handler": { "ExpressHttpServerFactory:_handler": {
"@id": "urn:solid-server:default:HttpHandler" "@id": "urn:solid-server:default:HttpHandler"
} }
},
{
"@id": "urn:solid-server:default:HttpHandler",
"@type": "AllVoidCompositeHandler",
"AllVoidCompositeHandler:_handlers": [
{
"@id": "urn:solid-server:default:Middleware"
},
{
"@id": "urn:solid-server:default:LdpHandler"
}
]
} }
] ]
} }

View File

@ -2,7 +2,7 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"@id": "urn:solid-server:default:HttpHandler", "@id": "urn:solid-server:default:LdpHandler",
"@type": "AuthenticatedLdpHandler", "@type": "AuthenticatedLdpHandler",
"AuthenticatedLdpHandler:_args_requestParser": { "AuthenticatedLdpHandler:_args_requestParser": {
"@id": "urn:solid-server:default:RequestParser" "@id": "urn:solid-server:default:RequestParser"

View File

@ -0,0 +1,17 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:Middleware",
"@type": "AllVoidCompositeHandler",
"AllVoidCompositeHandler:_handlers": [
{
"@type": "CorsHandler"
},
{
"@type": "HeaderHandler"
}
]
}
]
}

View File

@ -95,6 +95,10 @@ export * from './src/server/HttpResponse';
export * from './src/server/WebSocketServerFactory'; export * from './src/server/WebSocketServerFactory';
export * from './src/server/WebSocketHandler'; export * from './src/server/WebSocketHandler';
// Server/Middleware
export * from './src/server/middleware/CorsHandler';
export * from './src/server/middleware/HeaderHandler';
// Storage/Accessors // Storage/Accessors
export * from './src/storage/accessors/DataAccessor'; export * from './src/storage/accessors/DataAccessor';
export * from './src/storage/accessors/FileDataAccessor'; export * from './src/storage/accessors/FileDataAccessor';

View File

@ -1,5 +1,4 @@
import type { Server } from 'http'; import type { Server } from 'http';
import cors from 'cors';
import type { Express } from 'express'; import type { Express } from 'express';
import express from 'express'; import express from 'express';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
@ -17,28 +16,11 @@ export class ExpressHttpServerFactory implements HttpServerFactory {
} }
public startServer(port: number): Server { public startServer(port: number): Server {
const app = express(); return this.createApp().listen(port);
this.setup(app);
return app.listen(port);
} }
protected setup(app: Express): void { protected createApp(): Express {
// Set up server identification return express().use(async(request, response, done): Promise<void> => {
app.use((request, response, done): void => {
response.setHeader('X-Powered-By', 'Community Solid Server');
done();
});
// Set up Cross-Origin Resource Sharing (CORS)
app.use(cors({
// Based on https://github.com/solid/solid-spec/blob/master/recommendations-server.md#cors---cross-origin-resource-sharing
// By default origin is always '*', this forces it to be the origin header if there is one
origin: (origin, callback): void => callback(null, (origin ?? '*') as any),
methods: [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ],
}));
// Delegate to the main handler
app.use(async(request, response, done): Promise<void> => {
try { try {
this.logger.info(`Received request for ${request.url}`); this.logger.info(`Received request for ${request.url}`);
await this.handler.handleSafe({ request: guardStream(request), response }); await this.handler.handleSafe({ request: guardStream(request), response });

View File

@ -0,0 +1,26 @@
import cors from 'cors';
import type { RequestHandler } from 'express';
import { HttpHandler } from '../HttpHandler';
import type { HttpRequest } from '../HttpRequest';
import type { HttpResponse } from '../HttpResponse';
/**
* Handler that sets CORS options on the response.
*/
export class CorsHandler extends HttpHandler {
private readonly corsHandler: RequestHandler;
public constructor() {
super();
this.corsHandler = cors({
origin: (origin, callback): void => callback(null, (origin ?? '*') as any),
methods: [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ],
});
}
public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise<void> {
return new Promise((resolve): void => {
this.corsHandler(input.request as any, input.response as any, (): void => resolve());
});
}
}

View File

@ -0,0 +1,22 @@
import { HttpHandler } from '../HttpHandler';
import type { HttpResponse } from '../HttpResponse';
/**
* Handler that sets custom headers on the response.
*/
export class HeaderHandler extends HttpHandler {
private readonly headers: Record<string, string>;
public constructor() {
super();
this.headers = {
'x-powered-by': 'Community Solid Server',
};
}
public async handle({ response }: { response: HttpResponse }): Promise<void> {
for (const header of Object.keys(this.headers)) {
response.setHeader(header, this.headers[header]);
}
}
}

View File

@ -0,0 +1,13 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/presets/http.json",
"files-scs:config/presets/middleware.json"
],
"@graph": [
{
"@id": "urn:solid-server:default:LdpHandler",
"@type": "Variable"
}
]
}

View File

@ -9,6 +9,7 @@
"files-scs:config/presets/ldp/response-writer.json", "files-scs:config/presets/ldp/response-writer.json",
"files-scs:config/presets/ldp/request-parser.json", "files-scs:config/presets/ldp/request-parser.json",
"files-scs:config/presets/ldp/websockets.json", "files-scs:config/presets/ldp/websockets.json",
"files-scs:config/presets/middleware.json",
"files-scs:config/presets/representation-conversion.json", "files-scs:config/presets/representation-conversion.json",
"files-scs:config/presets/storage/backend/storage-memory.json", "files-scs:config/presets/storage/backend/storage-memory.json",
"files-scs:config/presets/storage/routing/no-routing.json", "files-scs:config/presets/storage/routing/no-routing.json",
@ -17,7 +18,7 @@
], ],
"@graph": [ "@graph": [
{ {
"@id": "urn:solid-server:default:HttpHandler", "@id": "urn:solid-server:default:LdpHandler",
"@type": "AuthenticatedLdpHandler", "@type": "AuthenticatedLdpHandler",
"AuthenticatedLdpHandler:_args_requestParser": { "AuthenticatedLdpHandler:_args_requestParser": {
"@id": "urn:solid-server:default:RequestParser" "@id": "urn:solid-server:default:RequestParser"

View File

@ -0,0 +1,69 @@
import type { Server } from 'http';
import request from 'supertest';
import type { ExpressHttpServerFactory } from '../../src/server/ExpressHttpServerFactory';
import { HttpHandler } from '../../src/server/HttpHandler';
import type { HttpRequest } from '../../src/server/HttpRequest';
import type { HttpResponse } from '../../src/server/HttpResponse';
import { instantiateFromConfig } from '../configs/Util';
const port = 6002;
class SimpleHttpHandler extends HttpHandler {
public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise<void> {
input.response.writeHead(200);
input.response.end('Hello World');
}
}
describe('An Express server with middleware', (): void => {
let server: Server;
beforeAll(async(): Promise<void> => {
const factory = await instantiateFromConfig(
'urn:solid-server:default:ExpressHttpServerFactory', 'middleware.json', {
'urn:solid-server:default:LdpHandler': new SimpleHttpHandler(),
},
) as ExpressHttpServerFactory;
server = factory.startServer(port);
});
afterEach(async(): Promise<void> => {
server.close();
});
it('sends server identification in the X-Powered-By header.', async(): Promise<void> => {
const res = await request(server).get('/');
expect(res.header).toEqual(expect.objectContaining({
'x-powered-by': 'Community Solid Server',
}));
});
it('returns CORS headers for an OPTIONS request.', async(): Promise<void> => {
const res = await request(server)
.options('/')
.set('Access-Control-Request-Headers', 'content-type')
.set('Access-Control-Request-Method', 'POST')
.set('Host', 'test.com')
.expect(204);
expect(res.header).toEqual(expect.objectContaining({
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type',
}));
const corsMethods = res.header['access-control-allow-methods'].split(',')
.map((method: string): string => method.trim());
const allowedMethods = [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ];
expect(corsMethods).toEqual(expect.arrayContaining(allowedMethods));
expect(corsMethods).toHaveLength(allowedMethods.length);
});
it('specifies CORS origin header if an origin was supplied.', async(): Promise<void> => {
const res = await request(server).get('/').set('origin', 'test.com').expect(200);
expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
});
it('sends incoming requests to the handler.', async(): Promise<void> => {
const response = request(server).get('/').set('Host', 'test.com');
expect(response).toBeDefined();
await response.expect(200).expect('Hello World');
});
});

View File

@ -42,7 +42,6 @@ describe('ExpressHttpServerFactory', (): void => {
}); });
afterEach(async(): Promise<void> => { afterEach(async(): Promise<void> => {
// Close server
server.close(); server.close();
}); });
@ -50,36 +49,6 @@ describe('ExpressHttpServerFactory', (): void => {
mock.mockReset(); mock.mockReset();
}); });
it('sends server identification in the X-Powered-By header.', async(): Promise<void> => {
const res = await request(server).get('/');
expect(res.header).toEqual(expect.objectContaining({
'x-powered-by': 'Community Solid Server',
}));
});
it('returns CORS headers for an OPTIONS request.', async(): Promise<void> => {
const res = await request(server)
.options('/')
.set('Access-Control-Request-Headers', 'content-type')
.set('Access-Control-Request-Method', 'POST')
.set('Host', 'test.com')
.expect(204);
expect(res.header).toEqual(expect.objectContaining({
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type',
}));
const corsMethods = res.header['access-control-allow-methods'].split(',')
.map((method: string): string => method.trim());
const allowedMethods = [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ];
expect(corsMethods).toEqual(expect.arrayContaining(allowedMethods));
expect(corsMethods).toHaveLength(allowedMethods.length);
});
it('specifies CORS origin header if an origin was supplied.', async(): Promise<void> => {
const res = await request(server).get('/').set('origin', 'test.com').expect(200);
expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
});
it('sends incoming requests to the handler.', async(): Promise<void> => { it('sends incoming requests to the handler.', async(): Promise<void> => {
await request(server).get('/').set('Host', 'test.com').expect(200); await request(server).get('/').set('Host', 'test.com').expect(200);
expect(canHandleJest).toHaveBeenCalledTimes(1); expect(canHandleJest).toHaveBeenCalledTimes(1);

View File

@ -0,0 +1,33 @@
import { createRequest, createResponse } from 'node-mocks-http';
import { CorsHandler } from '../../../../src/server/middleware/CorsHandler';
import { guardStream } from '../../../../src/util/GuardedStream';
describe('a CorsHandler', (): void => {
let handler: CorsHandler;
beforeAll(async(): Promise<void> => {
handler = new CorsHandler();
});
it('returns CORS headers.', async(): Promise<void> => {
const request = guardStream(createRequest());
const response = createResponse();
await handler.handleSafe({ request, response });
expect(response.getHeaders()).toEqual(expect.objectContaining({
'access-control-allow-origin': '*',
}));
});
it('echoes the origin when specified.', async(): Promise<void> => {
const request = guardStream(createRequest({
headers: {
origin: 'example.org',
},
}));
const response = createResponse();
await handler.handleSafe({ request, response });
expect(response.getHeaders()).toEqual(expect.objectContaining({
'access-control-allow-origin': 'example.org',
}));
});
});

View File

@ -0,0 +1,20 @@
import { createRequest, createResponse } from 'node-mocks-http';
import { HeaderHandler } from '../../../../src/server/middleware/HeaderHandler';
import { guardStream } from '../../../../src/util/GuardedStream';
describe('a HeaderHandler', (): void => {
let handler: HeaderHandler;
beforeAll(async(): Promise<void> => {
handler = new HeaderHandler();
});
it('returns an X-Powered-By header.', async(): Promise<void> => {
const request = guardStream(createRequest());
const response = createResponse();
await handler.handleSafe({ request, response });
expect(response.getHeaders()).toEqual(expect.objectContaining({
'x-powered-by': 'Community Solid Server',
}));
});
});