Skip to content

Commit

Permalink
feat: Dynamically generate Allow and Accept-* headers
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Mar 29, 2022
1 parent effc20a commit 6e98c6a
Show file tree
Hide file tree
Showing 15 changed files with 326 additions and 18 deletions.
3 changes: 3 additions & 0 deletions config/http/middleware/handlers/cors.json
Expand Up @@ -18,6 +18,9 @@
"options_preflightContinue": true,
"options_exposedHeaders": [
"Accept-Patch",
"Accept-Post",
"Accept-Put",
"Allow",
"ETag",
"Last-Modified",
"Link",
Expand Down
2 changes: 2 additions & 0 deletions config/ldp/metadata-writer/default.json
@@ -1,6 +1,7 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"import": [
"files-scs:config/ldp/metadata-writer/writers/allow-accept.json",
"files-scs:config/ldp/metadata-writer/writers/constant.json",
"files-scs:config/ldp/metadata-writer/writers/link-rel.json",
"files-scs:config/ldp/metadata-writer/writers/mapped.json",
Expand All @@ -14,6 +15,7 @@
"@id": "urn:solid-server:default:MetadataWriter",
"@type": "ParallelHandler",
"handlers": [
{ "@id": "urn:solid-server:default:MetadataWriter_AllowAccept" },
{ "@id": "urn:solid-server:default:MetadataWriter_Constant" },
{ "@id": "urn:solid-server:default:MetadataWriter_Mapped" },
{ "@id": "urn:solid-server:default:MetadataWriter_Modified" },
Expand Down
14 changes: 14 additions & 0 deletions config/ldp/metadata-writer/writers/allow-accept.json
@@ -0,0 +1,14 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Adds Allow and Accept-[Method] headers.",
"@id": "urn:solid-server:default:MetadataWriter_AllowAccept",
"@type": "AllowAcceptHeaderWriter",
"supportedMethods": [ "OPTIONS", "HEAD", "GET", "PATCH", "POST", "PUT", "DELETE" ],
"acceptTypes_patch": [ "text/n3", "application/sparql-update" ],
"acceptTypes_put": [ "*/*" ],
"acceptTypes_post": [ "*/*" ]
}
]
}
8 changes: 0 additions & 8 deletions config/ldp/metadata-writer/writers/constant.json
Expand Up @@ -6,14 +6,6 @@
"@id": "urn:solid-server:default:MetadataWriter_Constant",
"@type": "ConstantMetadataWriter",
"headers": [
{
"ConstantMetadataWriter:_headers_key": "Accept-Patch",
"ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3"
},
{
"ConstantMetadataWriter:_headers_key": "Allow",
"ConstantMetadataWriter:_headers_value": "OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE"
},
{
"ConstantMetadataWriter:_headers_key": "MS-Author-Via",
"ConstantMetadataWriter:_headers_value": "SPARQL"
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -116,7 +116,7 @@
"lodash.orderby": "^4.6.0",
"marked": "^4.0.12",
"mime-types": "^2.1.34",
"n3": "^1.13.0",
"n3": "^1.16.0",
"nodemailer": "^6.7.2",
"oidc-provider": "^7.10.6",
"pump": "^3.0.0",
Expand Down
139 changes: 139 additions & 0 deletions src/http/output/metadata/AllowAcceptHeaderWriter.ts
@@ -0,0 +1,139 @@
import type { HttpResponse } from '../../../server/HttpResponse';
import { MethodNotAllowedHttpError } from '../../../util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError';
import { addHeader } from '../../../util/HeaderUtil';
import { isContainerPath } from '../../../util/PathUtil';
import { LDP, PIM, RDF, SOLID_ERROR } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';

// Only PUT and PATCH can be used to create a new resource
const NEW_RESOURCE_ALLOWED_METHODS = new Set([ 'PUT', 'PATCH' ]);

/**
* Generates Allow, Accept-Patch, Accept-Post, and Accept-Put headers.
* The resulting values depend on the choses input methods and types.
* The input metadata also gets used to remove methods from that list
* if they are not valid in the given situation.
*/
export class AllowAcceptHeaderWriter extends MetadataWriter {
private readonly supportedMethods: string[];
private readonly acceptTypes: { patch: string[]; post: string[]; put: string[] };

public constructor(supportedMethods: string[], acceptTypes: { patch?: string[]; post?: string[]; put?: string[] }) {
super();
this.supportedMethods = supportedMethods;
this.acceptTypes = { patch: [], post: [], put: [], ...acceptTypes };
}

public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const { response, metadata } = input;

// Filter out methods which are not allowed
const allowedMethods = this.filterAllowedMethods(metadata);

// Generate the Allow headers (if required)
const generateAllow = this.generateAllow(allowedMethods, response, metadata);

// Generate Accept-[Method] headers (if required)
this.generateAccept(allowedMethods, generateAllow, response, metadata);
}

/**
* Starts from the stored set of methods and removes all those that are not allowed based on the metadata.
*/
private filterAllowedMethods(metadata: RepresentationMetadata): Set<string> {
const disallowedMethods = new Set(metadata.getAll(SOLID_ERROR.terms.disallowedMethod)
.map((term): string => term.value));
const allowedMethods = new Set(this.supportedMethods.filter((method): boolean => !disallowedMethods.has(method)));

// POST is only allowed on containers.
// Metadata only has the resource URI in case it has resource metadata.
if (this.isPostAllowed(metadata)) {
allowedMethods.delete('POST');
}

if (!this.isDeleteAllowed(metadata)) {
allowedMethods.delete('DELETE');
}

// If we are sure the resource does not exist: only keep methods that can create a new resource.
if (metadata.has(SOLID_ERROR.terms.errorResponse, NotFoundHttpError.uri)) {
for (const method of allowedMethods) {
if (!NEW_RESOURCE_ALLOWED_METHODS.has(method)) {
allowedMethods.delete(method);
}
}
}

return allowedMethods;
}

/**
* POST is only allowed on containers.
* The metadata URI is only valid in case there is resource metadata,
* otherwise it is just a blank node.
*/
private isPostAllowed(metadata: RepresentationMetadata): boolean {
return metadata.has(RDF.terms.type, LDP.terms.Resource) && !isContainerPath(metadata.identifier.value);
}

/**
* DELETE is allowed if the target exists,
* is not a container,
* or is an empty container that isn't a storage.
*
* Note that the identifier value check only works if the metadata is not about an error.
*/
private isDeleteAllowed(metadata: RepresentationMetadata): boolean {
if (!isContainerPath(metadata.identifier.value)) {
return true;
}

const isStorage = metadata.has(RDF.terms.type, PIM.terms.Storage);
const isEmpty = metadata.has(LDP.terms.contains);
return !isStorage && !isEmpty;
}

/**
* Generates the Allow header if required.
* It only needs to get added for successful GET/HEAD requests, 404s, or 405s.
* The spec only requires it for GET/HEAD requests and 405s.
* In the case of other error messages we can't deduce what the request method was,
* so we do not add the header as we don't have enough information.
*/
private generateAllow(methods: Set<string>, response: HttpResponse, metadata: RepresentationMetadata): boolean {
const methodDisallowed = metadata.has(SOLID_ERROR.terms.errorResponse, MethodNotAllowedHttpError.uri);
// 405s indicate the target resource exists.
// This is a heuristic, but one that should always be correct in our case.
const resourceExists = methodDisallowed || metadata.has(RDF.terms.type, LDP.terms.Resource);
const generateAllow = resourceExists || metadata.has(SOLID_ERROR.terms.errorResponse, NotFoundHttpError.uri);
if (generateAllow) {
addHeader(response, 'Allow', [ ...methods ].join(', '));
}
return generateAllow;
}

/**
* Generates the Accept-[Method] headers if required.
* Will be added if the Allow header was added, or in case of a 415 error.
* Specific Accept-[Method] headers will only be added if the method is in the `methods` set.
*/
private generateAccept(methods: Set<string>, generateAllow: boolean, response: HttpResponse,
metadata: RepresentationMetadata): void {
const typeWasUnsupported = metadata.has(SOLID_ERROR.terms.errorResponse, UnsupportedMediaTypeHttpError.uri);
const generateAccept = generateAllow || typeWasUnsupported;
if (generateAccept) {
if (methods.has('PATCH')) {
addHeader(response, 'Accept-Patch', this.acceptTypes.patch.join(', '));
}
if (methods.has('POST')) {
addHeader(response, 'Accept-Post', this.acceptTypes.post.join(', '));
}
if (methods.has('PUT')) {
addHeader(response, 'Accept-Put', this.acceptTypes.put.join(', '));
}
}
}
}
14 changes: 14 additions & 0 deletions src/http/representation/RepresentationMetadata.ts
Expand Up @@ -257,6 +257,20 @@ export class RepresentationMetadata {
return this;
}

/**
* Verifies if a specific triple can be found in the metadata.
* Undefined parameters are interpreted as wildcards.
*/
public has(
predicate: NamedNode | string | null = null,
object: NamedNode | BlankNode | Literal | string | null = null,
graph: MetadataGraph | null = null,
): boolean {
// This works with N3.js but at the time of writing the typings have not been updated yet.
// If you see this line of code check if the typings are already correct and update this if so.
return (this.store.has as any)(this.id, predicate, object, graph);
}

/**
* Finds all object values matching the given predicate and/or graph.
* @param predicate - Optional predicate to get the values for.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -94,6 +94,7 @@ export * from './http/output/error/RedirectingErrorHandler';
export * from './http/output/error/SafeErrorHandler';

// HTTP/Output/Metadata
export * from './http/output/metadata/AllowAcceptHeaderWriter';
export * from './http/output/metadata/ConstantMetadataWriter';
export * from './http/output/metadata/LinkRelMetadataWriter';
export * from './http/output/metadata/MappedMetadataWriter';
Expand Down
5 changes: 5 additions & 0 deletions src/server/ParsingHttpHandler.ts
Expand Up @@ -6,6 +6,7 @@ import type { ResponseWriter } from '../http/output/ResponseWriter';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import { getLoggerFor } from '../logging/LogUtil';
import { assertError } from '../util/errors/ErrorUtil';
import { HttpError } from '../util/errors/HttpError';
import type { HttpHandlerInput } from './HttpHandler';
import { HttpHandler } from './HttpHandler';
import type { OperationHttpHandler } from './OperationHttpHandler';
Expand Down Expand Up @@ -73,6 +74,10 @@ export class ParsingHttpHandler extends HttpHandler {
} catch (error: unknown) {
assertError(error);
result = await this.errorHandler.handleSafe({ error, preferences });
if (HttpError.isInstance(error) && result.metadata) {
const quads = error.generateMetadata(result.metadata.identifier);
result.metadata.addQuads(quads);
}
}

if (result) {
Expand Down
4 changes: 3 additions & 1 deletion test/integration/Middleware.test.ts
Expand Up @@ -93,10 +93,12 @@ describe('An http server with middleware', (): void => {
expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
});

it('exposes the Accept-Patch header via CORS.', async(): Promise<void> => {
it('exposes the Accept-[Method] header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const exposed = res.header['access-control-expose-headers'];
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Patch');
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Post');
expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Put');
});

it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise<void> => {
Expand Down

0 comments on commit 6e98c6a

Please sign in to comment.