diff --git a/config/ldp/metadata-writer/writers/mapped.json b/config/ldp/metadata-writer/writers/mapped.json index ea6ef7e51..74c3b1254 100644 --- a/config/ldp/metadata-writer/writers/mapped.json +++ b/config/ldp/metadata-writer/writers/mapped.json @@ -11,7 +11,7 @@ "MappedMetadataWriter:_headerMap_value": "Content-Type" }, { - "MappedMetadataWriter:_headerMap_key": "urn:solid:http:location", + "MappedMetadataWriter:_headerMap_key": "urn:npm:solid:community-server:http:location", "MappedMetadataWriter:_headerMap_value": "Location" } ] diff --git a/src/ldp/http/ConvertingErrorHandler.ts b/src/ldp/http/ConvertingErrorHandler.ts new file mode 100644 index 000000000..35d9ef50d --- /dev/null +++ b/src/ldp/http/ConvertingErrorHandler.ts @@ -0,0 +1,94 @@ +import type { + RepresentationConverter, + RepresentationConverterArgs, +} from '../../storage/conversion/RepresentationConverter'; +import { INTERNAL_ERROR } from '../../util/ContentTypes'; +import { getStatusCode } from '../../util/errors/ErrorUtil'; +import { toLiteral } from '../../util/TermUtil'; +import { HTTP, XSD } from '../../util/Vocabularies'; +import { BasicRepresentation } from '../representation/BasicRepresentation'; +import type { Representation } from '../representation/Representation'; +import { RepresentationMetadata } from '../representation/RepresentationMetadata'; +import type { ErrorHandlerArgs } from './ErrorHandler'; +import { ErrorHandler } from './ErrorHandler'; +import type { ResponseDescription } from './response/ResponseDescription'; + +// Used by internal helper function +type PreparedArguments = { + statusCode: number; + conversionArgs: RepresentationConverterArgs; +}; + +/** + * Converts an error into a Representation of content type internal/error. + * Then feeds that representation into its converter to create a representation based on the given preferences. + */ +export class ConvertingErrorHandler extends ErrorHandler { + private readonly converter: RepresentationConverter; + private readonly showStackTrace: boolean; + + public constructor(converter: RepresentationConverter, showStackTrace = false) { + super(); + this.converter = converter; + this.showStackTrace = showStackTrace; + } + + public async canHandle(input: ErrorHandlerArgs): Promise { + const { conversionArgs } = this.prepareArguments(input); + + await this.converter.canHandle(conversionArgs); + } + + public async handle(input: ErrorHandlerArgs): Promise { + const { statusCode, conversionArgs } = this.prepareArguments(input); + + const converted = await this.converter.handle(conversionArgs); + + return this.createResponse(statusCode, converted); + } + + public async handleSafe(input: ErrorHandlerArgs): Promise { + const { statusCode, conversionArgs } = this.prepareArguments(input); + + const converted = await this.converter.handleSafe(conversionArgs); + + return this.createResponse(statusCode, converted); + } + + /** + * Prepares the arguments used by all functions. + */ + private prepareArguments({ error, preferences }: ErrorHandlerArgs): PreparedArguments { + const statusCode = getStatusCode(error); + const representation = this.toRepresentation(error, statusCode); + const identifier = { path: representation.metadata.identifier.value }; + return { statusCode, conversionArgs: { identifier, representation, preferences }}; + } + + /** + * Creates a ResponseDescription based on the Representation. + */ + private createResponse(statusCode: number, converted: Representation): ResponseDescription { + return { + statusCode, + metadata: converted.metadata, + data: converted.data, + }; + } + + /** + * Creates a Representation based on the given error. + * Content type will be internal/error. + * The status code is used for metadata. + */ + private toRepresentation(error: Error, statusCode: number): Representation { + const metadata = new RepresentationMetadata(INTERNAL_ERROR); + metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer)); + + if (!this.showStackTrace) { + delete error.stack; + } + + return new BasicRepresentation([ error ], metadata, false); + } +} diff --git a/src/ldp/http/ErrorHandler.ts b/src/ldp/http/ErrorHandler.ts new file mode 100644 index 000000000..fa3dcc79b --- /dev/null +++ b/src/ldp/http/ErrorHandler.ts @@ -0,0 +1,13 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { RepresentationPreferences } from '../representation/RepresentationPreferences'; +import type { ResponseDescription } from './response/ResponseDescription'; + +export interface ErrorHandlerArgs { + error: Error; + preferences: RepresentationPreferences; +} + +/** + * Converts an error into a {@link ResponseDescription} based on the request preferences. + */ +export abstract class ErrorHandler extends AsyncHandler {} diff --git a/src/ldp/http/TextErrorHandler.ts b/src/ldp/http/TextErrorHandler.ts new file mode 100644 index 000000000..d93363a93 --- /dev/null +++ b/src/ldp/http/TextErrorHandler.ts @@ -0,0 +1,37 @@ +import { getStatusCode } from '../../util/errors/ErrorUtil'; +import { guardedStreamFrom } from '../../util/StreamUtil'; +import { toLiteral } from '../../util/TermUtil'; +import { HTTP, XSD } from '../../util/Vocabularies'; +import { RepresentationMetadata } from '../representation/RepresentationMetadata'; +import type { ErrorHandlerArgs } from './ErrorHandler'; +import { ErrorHandler } from './ErrorHandler'; +import type { ResponseDescription } from './response/ResponseDescription'; + +/** + * Returns a simple text description of an error. + * This class is mostly a failsafe in case all other solutions fail. + */ +export class TextErrorHandler extends ErrorHandler { + private readonly showStackTrace: boolean; + + public constructor(showStackTrace = false) { + super(); + this.showStackTrace = showStackTrace; + } + + public async handle({ error }: ErrorHandlerArgs): Promise { + const statusCode = getStatusCode(error); + const metadata = new RepresentationMetadata('text/plain'); + metadata.add(HTTP.terms.statusCodeNumber, toLiteral(statusCode, XSD.terms.integer)); + + const text = typeof error.stack === 'string' && this.showStackTrace ? + `${error.stack}\n` : + `${error.name}: ${error.message}\n`; + + return { + statusCode, + metadata, + data: guardedStreamFrom(text), + }; + } +} diff --git a/src/ldp/http/metadata/SlugParser.ts b/src/ldp/http/metadata/SlugParser.ts index 6324981a8..16e30422d 100644 --- a/src/ldp/http/metadata/SlugParser.ts +++ b/src/ldp/http/metadata/SlugParser.ts @@ -1,7 +1,7 @@ import { getLoggerFor } from '../../../logging/LogUtil'; import type { HttpRequest } from '../../../server/HttpRequest'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { HTTP } from '../../../util/Vocabularies'; +import { SOLID_HTTP } from '../../../util/Vocabularies'; import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import { MetadataParser } from './MetadataParser'; @@ -19,7 +19,7 @@ export class SlugParser extends MetadataParser { throw new BadRequestHttpError('Request has multiple Slug headers'); } this.logger.debug(`Request Slug is '${slug}'.`); - input.metadata.set(HTTP.slug, slug); + input.metadata.set(SOLID_HTTP.slug, slug); } } } diff --git a/src/ldp/http/response/CreatedResponseDescription.ts b/src/ldp/http/response/CreatedResponseDescription.ts index a8603e1a3..7d35ac75d 100644 --- a/src/ldp/http/response/CreatedResponseDescription.ts +++ b/src/ldp/http/response/CreatedResponseDescription.ts @@ -1,5 +1,5 @@ import { DataFactory } from 'n3'; -import { HTTP } from '../../../util/Vocabularies'; +import { SOLID_HTTP } from '../../../util/Vocabularies'; import { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../representation/ResourceIdentifier'; import { ResponseDescription } from './ResponseDescription'; @@ -9,7 +9,7 @@ import { ResponseDescription } from './ResponseDescription'; */ export class CreatedResponseDescription extends ResponseDescription { public constructor(location: ResourceIdentifier) { - const metadata = new RepresentationMetadata({ [HTTP.location]: DataFactory.namedNode(location.path) }); + const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location.path) }); super(201, metadata); } } diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts index 5df621a50..439b0713c 100644 --- a/src/storage/DataAccessorBasedStore.ts +++ b/src/storage/DataAccessorBasedStore.ts @@ -26,7 +26,7 @@ import { } from '../util/PathUtil'; import { parseQuads } from '../util/QuadUtil'; import { addResourceMetadata } from '../util/ResourceUtil'; -import { CONTENT_TYPE, DC, HTTP, LDP, POSIX, PIM, RDF, VANN, XSD } from '../util/Vocabularies'; +import { CONTENT_TYPE, DC, SOLID_HTTP, LDP, POSIX, PIM, RDF, VANN, XSD } from '../util/Vocabularies'; import type { DataAccessor } from './accessors/DataAccessor'; import type { ResourceStore } from './ResourceStore'; @@ -407,8 +407,8 @@ export class DataAccessorBasedStore implements ResourceStore { Promise { // Get all values needed for naming the resource const isContainer = this.isNewContainer(metadata); - const slug = metadata.get(HTTP.slug)?.value; - metadata.removeAll(HTTP.slug); + const slug = metadata.get(SOLID_HTTP.slug)?.value; + metadata.removeAll(SOLID_HTTP.slug); let newID: ResourceIdentifier = this.createURI(container, isContainer, slug); @@ -439,7 +439,7 @@ export class DataAccessorBasedStore implements ResourceStore { if (this.hasContainerType(metadata.getAll(RDF.type))) { return true; } - const slug = suffix ?? metadata.get(HTTP.slug)?.value; + const slug = suffix ?? metadata.get(SOLID_HTTP.slug)?.value; return Boolean(slug && isContainerPath(slug)); } diff --git a/src/storage/conversion/ErrorToQuadConverter.ts b/src/storage/conversion/ErrorToQuadConverter.ts new file mode 100644 index 000000000..9591c236b --- /dev/null +++ b/src/storage/conversion/ErrorToQuadConverter.ts @@ -0,0 +1,37 @@ +import arrayifyStream from 'arrayify-stream'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; +import { INTERNAL_ERROR, INTERNAL_QUADS } from '../../util/ContentTypes'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { DC, SOLID_ERROR } from '../../util/Vocabularies'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; + +/** + * Converts an error object into quads by creating a triple for each of name/message/stack. + */ +export class ErrorToQuadConverter extends TypedRepresentationConverter { + public constructor() { + super(INTERNAL_ERROR, INTERNAL_QUADS); + } + + public async handle({ identifier, representation }: RepresentationConverterArgs): Promise { + const errors = await arrayifyStream(representation.data); + if (errors.length !== 1) { + throw new InternalServerError('Only single errors are supported.'); + } + const error = errors[0] as Error; + + // A metadata object makes it easier to add triples due to the utility functions + const data = new RepresentationMetadata(identifier); + data.add(DC.terms.title, error.name); + data.add(DC.terms.description, error.message); + if (error.stack) { + data.add(SOLID_ERROR.terms.stack, error.stack); + } + + // Update the content-type to quads + return new BasicRepresentation(data.quads(), representation.metadata, INTERNAL_QUADS, false); + } +} diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index 82a9f4269..40cd83776 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -8,3 +8,4 @@ export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlenco // Internal content types (not exposed over HTTP) export const INTERNAL_ALL = 'internal/*'; export const INTERNAL_QUADS = 'internal/quads'; +export const INTERNAL_ERROR = 'internal/error'; diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index a8ab1b99c..b4fb7c048 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -75,16 +75,17 @@ export const AUTH = createUriAndTermNamespace('urn:solid:auth:', ); export const DC = createUriAndTermNamespace('http://purl.org/dc/terms/', + 'description', 'modified', + 'title', ); export const FOAF = createUriAndTermNamespace('http://xmlns.com/foaf/0.1/', 'Agent', ); -export const HTTP = createUriAndTermNamespace('urn:solid:http:', - 'location', - 'slug', +export const HTTP = createUriAndTermNamespace('http://www.w3.org/2011/http#', + 'statusCodeNumber', ); export const LDP = createUriAndTermNamespace('http://www.w3.org/ns/ldp#', @@ -112,6 +113,21 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s 'type', ); +export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#', + 'oidcIssuer', + 'oidcIssuerRegistrationToken', + 'oidcRegistration', +); + +export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:', + 'stack', +); + +export const SOLID_HTTP = createUriAndTermNamespace('urn:npm:solid:community-server:http:', + 'location', + 'slug', +); + export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/', 'preferredNamespacePrefix', ); @@ -121,12 +137,6 @@ export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#' 'integer', ); -export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#', - 'oidcIssuer', - 'oidcIssuerRegistrationToken', - 'oidcRegistration', -); - // Alias for commonly used types export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE_TERM = MA.terms.format; diff --git a/src/util/errors/ErrorUtil.ts b/src/util/errors/ErrorUtil.ts index 0c4d5c444..3d2dbce8b 100644 --- a/src/util/errors/ErrorUtil.ts +++ b/src/util/errors/ErrorUtil.ts @@ -1,4 +1,5 @@ import { types } from 'util'; +import { HttpError } from './HttpError'; /** * Checks if the input is an {@link Error}. @@ -6,3 +7,20 @@ import { types } from 'util'; export function isNativeError(error: any): error is Error { return types.isNativeError(error); } + +/** + * Asserts that the input is a native error. + * If not the input will be re-thrown. + */ +export function assertNativeError(error: any): asserts error is Error { + if (!isNativeError(error)) { + throw error; + } +} + +/** + * Returns the HTTP status code corresponding to the error. + */ +export function getStatusCode(error: Error): number { + return HttpError.isInstance(error) ? error.statusCode : 500; +} diff --git a/test/unit/ldp/http/ConvertingErrorHandler.test.ts b/test/unit/ldp/http/ConvertingErrorHandler.test.ts new file mode 100644 index 000000000..dd209244f --- /dev/null +++ b/test/unit/ldp/http/ConvertingErrorHandler.test.ts @@ -0,0 +1,95 @@ +import 'jest-rdf'; +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import { ConvertingErrorHandler } from '../../../../src/ldp/http/ConvertingErrorHandler'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import type { + RepresentationConverter, + RepresentationConverterArgs, +} from '../../../../src/storage/conversion/RepresentationConverter'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { HTTP, XSD } from '../../../../src/util/Vocabularies'; +import literal = DataFactory.literal; + +const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }}; + +async function expectValidArgs(args: RepresentationConverterArgs, stack?: string): Promise { + expect(args.preferences).toBe(preferences); + expect(args.representation.metadata.get(HTTP.terms.statusCodeNumber)) + .toEqualRdfTerm(literal(404, XSD.terms.integer)); + expect(args.representation.metadata.contentType).toBe('internal/error'); + + // Error contents + const errorArray = await arrayifyStream(args.representation.data); + expect(errorArray).toHaveLength(1); + const resultError = errorArray[0]; + expect(resultError).toMatchObject({ name: 'NotFoundHttpError', message: 'not here' }); + expect(resultError.stack).toBe(stack); +} + +describe('A ConvertingErrorHandler', (): void => { + // The error object can get modified by the handler + let error: Error; + let stack: string | undefined; + let converter: RepresentationConverter; + let handler: ConvertingErrorHandler; + + beforeEach(async(): Promise => { + error = new NotFoundHttpError('not here'); + ({ stack } = error); + converter = { + canHandle: jest.fn(), + handle: jest.fn((): Representation => new BasicRepresentation('serialization', 'text/turtle', true)), + handleSafe: jest.fn((): Representation => new BasicRepresentation('serialization', 'text/turtle', true)), + } as any; + + handler = new ConvertingErrorHandler(converter, true); + }); + + it('rejects input not supported by the converter.', async(): Promise => { + (converter.canHandle as jest.Mock).mockRejectedValueOnce(new Error('rejected')); + await expect(handler.canHandle({ error, preferences })).rejects.toThrow('rejected'); + expect(converter.canHandle).toHaveBeenCalledTimes(1); + const args = (converter.canHandle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; + expect(args.preferences).toBe(preferences); + expect(args.representation.metadata.contentType).toBe('internal/error'); + }); + + it('accepts input supported by the converter.', async(): Promise => { + await expect(handler.canHandle({ error, preferences })).resolves.toBeUndefined(); + expect(converter.canHandle).toHaveBeenCalledTimes(1); + const args = (converter.canHandle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; + expect(args.preferences).toBe(preferences); + expect(args.representation.metadata.contentType).toBe('internal/error'); + }); + + it('returns the converted error response.', async(): Promise => { + const prom = handler.handle({ error, preferences }); + await expect(prom).resolves.toMatchObject({ statusCode: 404 }); + expect((await prom).metadata?.contentType).toBe('text/turtle'); + expect(converter.handle).toHaveBeenCalledTimes(1); + const args = (converter.handle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; + await expectValidArgs(args, stack); + }); + + it('uses the handleSafe function of the converter during its own handleSafe call.', async(): Promise => { + const prom = handler.handleSafe({ error, preferences }); + await expect(prom).resolves.toMatchObject({ statusCode: 404 }); + expect((await prom).metadata?.contentType).toBe('text/turtle'); + expect(converter.handleSafe).toHaveBeenCalledTimes(1); + const args = (converter.handleSafe as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; + await expectValidArgs(args, stack); + }); + + it('hides the stack trace if the option is disabled.', async(): Promise => { + handler = new ConvertingErrorHandler(converter); + const prom = handler.handle({ error, preferences }); + await expect(prom).resolves.toMatchObject({ statusCode: 404 }); + expect((await prom).metadata?.contentType).toBe('text/turtle'); + expect(converter.handle).toHaveBeenCalledTimes(1); + const args = (converter.handle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; + await expectValidArgs(args); + }); +}); diff --git a/test/unit/ldp/http/TextErrorHandler.test.ts b/test/unit/ldp/http/TextErrorHandler.test.ts new file mode 100644 index 000000000..7c9121f9e --- /dev/null +++ b/test/unit/ldp/http/TextErrorHandler.test.ts @@ -0,0 +1,60 @@ +import 'jest-rdf'; +import { DataFactory } from 'n3'; +import stringifyStream from 'stream-to-string'; +import { TextErrorHandler } from '../../../../src/ldp/http/TextErrorHandler'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { HTTP, XSD } from '../../../../src/util/Vocabularies'; +import literal = DataFactory.literal; + +describe('A TextErrorHandler', (): void => { + // The error object can get modified by the handler + let error: Error; + let stack: string | undefined; + let handler: TextErrorHandler; + + beforeEach(async(): Promise => { + error = new NotFoundHttpError('not here'); + ({ stack } = error); + + handler = new TextErrorHandler(true); + }); + + it('can handle everything.', async(): Promise => { + await expect(handler.canHandle({} as any)).resolves.toBeUndefined(); + }); + + it('creates a text representation of the error.', async(): Promise => { + const prom = handler.handle({ error } as any); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.statusCode).toBe(404); + expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer)); + expect(result.metadata?.contentType).toBe('text/plain'); + const text = await stringifyStream(result.data!); + expect(text).toBe(`${stack}\n`); + }); + + it('concatenates name and message if there is no stack.', async(): Promise => { + delete error.stack; + const prom = handler.handle({ error } as any); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.statusCode).toBe(404); + expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer)); + expect(result.metadata?.contentType).toBe('text/plain'); + const text = await stringifyStream(result.data!); + expect(text).toBe(`NotFoundHttpError: not here\n`); + }); + + it('hides the stack trace if the option is disabled.', async(): Promise => { + handler = new TextErrorHandler(); + const prom = handler.handle({ error } as any); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.statusCode).toBe(404); + expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer)); + expect(result.metadata?.contentType).toBe('text/plain'); + const text = await stringifyStream(result.data!); + expect(text).toBe(`NotFoundHttpError: not here\n`); + }); +}); diff --git a/test/unit/ldp/http/metadata/SlugParser.test.ts b/test/unit/ldp/http/metadata/SlugParser.test.ts index 05c1a588a..3c415fef3 100644 --- a/test/unit/ldp/http/metadata/SlugParser.test.ts +++ b/test/unit/ldp/http/metadata/SlugParser.test.ts @@ -2,7 +2,7 @@ import { SlugParser } from '../../../../../src/ldp/http/metadata/SlugParser'; import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../../../../src/server/HttpRequest'; import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -import { HTTP } from '../../../../../src/util/Vocabularies'; +import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; describe('A SlugParser', (): void => { const parser = new SlugParser(); @@ -30,6 +30,6 @@ describe('A SlugParser', (): void => { request.headers.slug = 'slugA'; await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); expect(metadata.quads()).toHaveLength(1); - expect(metadata.get(HTTP.slug)?.value).toBe('slugA'); + expect(metadata.get(SOLID_HTTP.slug)?.value).toBe('slugA'); }); }); diff --git a/test/unit/ldp/operations/PostOperationHandler.test.ts b/test/unit/ldp/operations/PostOperationHandler.test.ts index 89d0e84ea..92b718121 100644 --- a/test/unit/ldp/operations/PostOperationHandler.test.ts +++ b/test/unit/ldp/operations/PostOperationHandler.test.ts @@ -5,7 +5,7 @@ import type { ResourceIdentifier } from '../../../../src/ldp/representation/Reso import type { ResourceStore } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { HTTP } from '../../../../src/util/Vocabularies'; +import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; describe('A PostOperationHandler', (): void => { const store = { @@ -31,7 +31,7 @@ describe('A PostOperationHandler', (): void => { const result = await handler.handle({ method: 'POST', body: { metadata }} as Operation); expect(result.statusCode).toBe(201); expect(result.metadata).toBeInstanceOf(RepresentationMetadata); - expect(result.metadata?.get(HTTP.location)?.value).toBe('newPath'); + expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath'); expect(result.data).toBeUndefined(); }); }); diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index 405941d69..75d423958 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -22,7 +22,7 @@ import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/Sing import { trimTrailingSlashes } from '../../../src/util/PathUtil'; import * as quadUtil from '../../../src/util/QuadUtil'; import { guardedStreamFrom } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies'; +import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies'; import quad = DataFactory.quad; import namedNode = DataFactory.namedNode; @@ -277,7 +277,7 @@ describe('A DataAccessorBasedStore', (): void => { it('creates a URI based on the incoming slug.', async(): Promise => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.type); - representation.metadata.add(HTTP.slug, 'newName'); + representation.metadata.add(SOLID_HTTP.slug, 'newName'); const result = await store.addResource(resourceID, representation); expect(result).toEqual({ path: `${root}newName`, @@ -286,7 +286,7 @@ describe('A DataAccessorBasedStore', (): void => { it('generates a new URI if adding the slug would create an existing URI.', async(): Promise => { const resourceID = { path: root }; - representation.metadata.add(HTTP.slug, 'newName'); + representation.metadata.add(SOLID_HTTP.slug, 'newName'); accessor.data[`${root}newName`] = representation; const result = await store.addResource(resourceID, representation); expect(result).not.toEqual({ @@ -300,7 +300,7 @@ describe('A DataAccessorBasedStore', (): void => { it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.type); - representation.metadata.add(HTTP.slug, '&%26'); + representation.metadata.add(SOLID_HTTP.slug, '&%26'); const result = await store.addResource(resourceID, representation); expect(result).toEqual({ path: `${root}%26%26` }); }); @@ -309,7 +309,7 @@ describe('A DataAccessorBasedStore', (): void => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.type); representation.data = guardedStreamFrom([ `` ]); - representation.metadata.add(HTTP.slug, 'sla/sh/es'); + representation.metadata.add(SOLID_HTTP.slug, 'sla/sh/es'); const result = store.addResource(resourceID, representation); await expect(result).rejects.toThrow(BadRequestHttpError); await expect(result).rejects.toThrow('Slugs should not contain slashes'); @@ -318,7 +318,7 @@ describe('A DataAccessorBasedStore', (): void => { it('errors if the slug would cause an auxiliary resource URI to be generated.', async(): Promise => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.type); - representation.metadata.add(HTTP.slug, 'test.dummy'); + representation.metadata.add(SOLID_HTTP.slug, 'test.dummy'); const result = store.addResource(resourceID, representation); await expect(result).rejects.toThrow(ForbiddenHttpError); await expect(result).rejects.toThrow('Slug bodies that would result in an auxiliary resource are forbidden'); diff --git a/test/unit/storage/conversion/ErrorToQuadConverter.test.ts b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts new file mode 100644 index 000000000..f5582acca --- /dev/null +++ b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts @@ -0,0 +1,59 @@ +import 'jest-rdf'; +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import { ErrorToQuadConverter } from '../../../../src/storage/conversion/ErrorToQuadConverter'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import { DC, SOLID_ERROR } from '../../../../src/util/Vocabularies'; +const { literal, namedNode, quad } = DataFactory; + +describe('An ErrorToQuadConverter', (): void => { + const identifier = { path: 'http://test.com/error' }; + const converter = new ErrorToQuadConverter(); + const preferences = {}; + + it('supports going from errors to quads.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); + await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 }); + }); + + it('does not support multiple errors.', async(): Promise => { + const representation = new BasicRepresentation([ new Error(), new Error() ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).rejects.toThrow('Only single errors are supported.'); + await expect(prom).rejects.toThrow(InternalServerError); + }); + + it('adds triples for all error fields.', async(): Promise => { + const error = new BadRequestHttpError('error text'); + const representation = new BasicRepresentation([ error ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(false); + expect(result.metadata.contentType).toBe('internal/quads'); + const quads = await arrayifyStream(result.data); + expect(quads).toBeRdfIsomorphic([ + quad(namedNode(identifier.path), DC.terms.title, literal('BadRequestHttpError')), + quad(namedNode(identifier.path), DC.terms.description, literal('error text')), + quad(namedNode(identifier.path), SOLID_ERROR.terms.stack, literal(error.stack!)), + ]); + }); + + it('only adds stack if it is defined.', async(): Promise => { + const error = new BadRequestHttpError('error text'); + delete error.stack; + const representation = new BasicRepresentation([ error ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(false); + expect(result.metadata.contentType).toBe('internal/quads'); + const quads = await arrayifyStream(result.data); + expect(quads).toBeRdfIsomorphic([ + quad(namedNode(identifier.path), DC.terms.title, literal('BadRequestHttpError')), + quad(namedNode(identifier.path), DC.terms.description, literal('error text')), + ]); + }); +}); diff --git a/test/unit/util/errors/ErrorUtil.test.ts b/test/unit/util/errors/ErrorUtil.test.ts new file mode 100644 index 000000000..8214c699e --- /dev/null +++ b/test/unit/util/errors/ErrorUtil.test.ts @@ -0,0 +1,34 @@ +import { assertNativeError, getStatusCode, isNativeError } from '../../../../src/util/errors/ErrorUtil'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; + +describe('ErrorUtil', (): void => { + describe('#isNativeError', (): void => { + it('returns true on native errors.', async(): Promise => { + expect(isNativeError(new Error('error'))).toBe(true); + }); + + it('returns false on other values.', async(): Promise => { + expect(isNativeError('apple')).toBe(false); + }); + }); + + describe('#assertNativeError', (): void => { + it('returns undefined on native errors.', async(): Promise => { + expect(assertNativeError(new Error('error'))).toBeUndefined(); + }); + + it('throws on other values.', async(): Promise => { + expect((): void => assertNativeError('apple')).toThrow('apple'); + }); + }); + + describe('#getStatusCode', (): void => { + it('returns the corresponding status code for HttpErrors.', async(): Promise => { + expect(getStatusCode(new NotFoundHttpError())).toBe(404); + }); + + it('returns 500 for other errors.', async(): Promise => { + expect(getStatusCode(new Error('404'))).toBe(500); + }); + }); +});