Repository: solid/community-server
Pull request #9 (2020-06-23 08:36:37)

Add simple implementations of all interfaces
With this pull request simple implementations are added, thereby allowing a full integration test starting at the ldp handler.

There is another ugly N3 cast which should also be fixed after changing the typing definitions.

ResponseDescription was added as an interface, representing the response that needs to be written back to the user. Currently this contains the identifier and optionally a body, but more would be needed to have a correct status code for example. This situation is actually similar to the problem with the errors. So here it could also be an option to encode the status code into the ResponseDescription. Or to instead embed the HTTP verb that was used and let the ResponseWriter do the conversion.

Another issue is who exactly would be responsible with generating a text/turle stream from the quads in the resource store. In the current implementation the resource store does this, based on the incoming accept header. But that would mean that it is impossible to request a stream of Quads from a resource store (since that is a javascript object and has no associated mediatype).

RubenVerborgh: I think stream of quads (with dummy content type) makes sense. I believe it’s discussed in the architecture document.
joachimvh: Yea I sort of lost track of adding JSdoc seeing as these were "temporary" classes, but I should probably still add some.

Regarding the content types, some quotes from the architecture:
> A ResourceStore will tryto satisfy any RepresentationPreferences passed to it, but only if this is reasonably easy for the store in question. 

So it definitely should be the ResourceStore who converts to the requested string data (turtle, n-triples, etc.) based on the preferences (if possible, if not, another text representation).

I don't see anything that directly references how to get a Quad stream from a resource store though. Only reference to the QuadRepresentation is this:
> For instance, a text/turtle stream  is  acceptable for a user agent that  requestedtext/*,  whereas a Readable<Quad> will still require serialization.

Just converting to raw quads when no content type is given might also be an issue since some identifiers might contain binary data instead of RDF data, of which sometimes it might be necessary to get the binary representation there (although there an existing content type could be used I guess). Unless with dummy content type you mean something like `contentType: 'quad'`.

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 2951a474a..089e6a3ca 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -13,11 +13,13 @@ module.exports = {
   ],
   rules: {
     '@typescript-eslint/no-empty-interface': 'off',
+    '@typescript-eslint/no-unnecessary-condition': 'off', // problems with optional parameters
     '@typescript-eslint/space-before-function-paren': [ 'error', 'never' ],
-    'class-methods-use-this': 'off',
+    'class-methods-use-this': 'off', // conflicts with functions from interfaces that sometimes don't require `this`
     'comma-dangle': ['error', 'always-multiline'],
     'dot-location': ['error', 'property'],
     'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
+    'no-underscore-dangle': 'off', // conflicts with external libraries
     'padding-line-between-statements': 'off',
     'tsdoc/syntax': 'error',
   },
diff --git a/package-lock.json b/package-lock.json
index a93f4edc6..248c3e085 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -749,18 +749,60 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/body-parser": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz",
+      "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==",
+      "dev": true,
+      "requires": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
     "@types/color-name": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
       "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
       "dev": true
     },
+    "@types/connect": {
+      "version": "3.4.33",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
+      "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/eslint-visitor-keys": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
       "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==",
       "dev": true
     },
+    "@types/express": {
+      "version": "4.17.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz",
+      "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==",
+      "dev": true,
+      "requires": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "*",
+        "@types/qs": "*",
+        "@types/serve-static": "*"
+      }
+    },
+    "@types/express-serve-static-core": {
+      "version": "4.17.7",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.7.tgz",
+      "integrity": "sha512-EMgTj/DF9qpgLXyc+Btimg+XoH7A2liE8uKul8qSmMTHCeNYzydDKFdsJskDvw42UsesCnhO63dO0Grbj8J4Dw==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*"
+      }
+    },
     "@types/graceful-fs": {
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.3.tgz",
@@ -871,6 +913,12 @@
       "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
       "dev": true
     },
+    "@types/mime": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.2.tgz",
+      "integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==",
+      "dev": true
+    },
     "@types/n3": {
       "version": "1.1.6",
       "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.1.6.tgz",
@@ -903,6 +951,18 @@
       "integrity": "sha512-/rM+sWiuOZ5dvuVzV37sUuklsbg+JPOP8d+nNFlo2ZtfpzPiPvh1/gc8liWOLBqe+sR+ZM7guPaIcTt6UZTo7Q==",
       "dev": true
     },
+    "@types/qs": {
+      "version": "6.9.3",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.3.tgz",
+      "integrity": "sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==",
+      "dev": true
+    },
+    "@types/range-parser": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
+      "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==",
+      "dev": true
+    },
     "@types/rdf-js": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-3.0.0.tgz",
@@ -911,6 +971,16 @@
         "@types/node": "*"
       }
     },
+    "@types/serve-static": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.4.tgz",
+      "integrity": "sha512-jTDt0o/YbpNwZbQmE/+2e+lfjJEJJR0I3OFaKQKPWkASkCoW3i6fsUnqudSMcNAfbtmADGu8f4MV4q+GqULmug==",
+      "dev": true,
+      "requires": {
+        "@types/express-serve-static-core": "*",
+        "@types/mime": "*"
+      }
+    },
     "@types/stack-utils": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
@@ -1006,6 +1076,16 @@
       "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==",
       "dev": true
     },
+    "accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+      "dev": true,
+      "requires": {
+        "mime-types": "~2.1.24",
+        "negotiator": "0.6.2"
+      }
+    },
     "acorn": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.2.0.tgz",
@@ -1779,6 +1859,12 @@
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
       "dev": true
     },
+    "depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
+      "dev": true
+    },
     "detect-newline": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -2676,6 +2762,12 @@
         "map-cache": "^0.2.2"
       }
     },
+    "fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=",
+      "dev": true
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -4095,12 +4187,30 @@
         "object-visit": "^1.0.0"
       }
     },
+    "media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
+      "dev": true
+    },
+    "merge-descriptors": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
+      "dev": true
+    },
     "merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
       "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
       "dev": true
     },
+    "methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
+      "dev": true
+    },
     "micromatch": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
@@ -4111,6 +4221,12 @@
         "picomatch": "^2.0.5"
       }
     },
+    "mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true
+    },
     "mime-db": {
       "version": "1.44.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
@@ -4225,6 +4341,12 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
     },
+    "negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==",
+      "dev": true
+    },
     "nice-try": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -4237,6 +4359,23 @@
       "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=",
       "dev": true
     },
+    "node-mocks-http": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.8.1.tgz",
+      "integrity": "sha512-qtd9YwXzCTdLfqjP7XSOtFei3TggwnjFIppmYEneQBaDIuknwgJTpItLskC5/pWOpU3lsK5aqdo+5CfIKHkXLg==",
+      "dev": true,
+      "requires": {
+        "accepts": "^1.3.7",
+        "depd": "^1.1.0",
+        "fresh": "^0.5.2",
+        "merge-descriptors": "^1.0.1",
+        "methods": "^1.1.2",
+        "mime": "^1.3.4",
+        "parseurl": "^1.3.3",
+        "range-parser": "^1.2.0",
+        "type-is": "^1.6.18"
+      }
+    },
     "node-modules-regexp": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz",
@@ -4536,6 +4675,12 @@
       "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==",
       "dev": true
     },
+    "parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "dev": true
+    },
     "pascalcase": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
@@ -4696,6 +4841,12 @@
       "integrity": "sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA==",
       "dev": true
     },
+    "range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "dev": true
+    },
     "rdf-isomorphic": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.1.0.tgz",
@@ -5969,6 +6120,16 @@
       "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
       "dev": true
     },
+    "type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "dev": true,
+      "requires": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      }
+    },
     "typedarray-to-buffer": {
       "version": "3.1.5",
       "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
diff --git a/package.json b/package.json
index 1a54f10cc..986a048ab 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
   },
   "devDependencies": {
     "@types/arrayify-stream": "^1.0.0",
+    "@types/express": "^4.17.6",
     "@types/jest": "^25.2.1",
     "@types/streamify-array": "^1.0.0",
     "@typescript-eslint/eslint-plugin": "^2.33.0",
@@ -40,6 +41,7 @@
     "husky": "^4.2.5",
     "jest": "^26.0.1",
     "jest-rdf": "^1.5.0",
+    "node-mocks-http": "^1.8.1",
     "streamify-array": "^1.0.1",
     "ts-jest": "^26.0.0",
     "typescript": "^3.9.2"
diff --git a/src/authentication/SimpleCredentialsExtractor.ts b/src/authentication/SimpleCredentialsExtractor.ts
new file mode 100644
index 000000000..874bbcd3b
--- /dev/null
+++ b/src/authentication/SimpleCredentialsExtractor.ts
@@ -0,0 +1,16 @@
+import { Credentials } from './Credentials';
+import { CredentialsExtractor } from './CredentialsExtractor';
+import { HttpRequest } from '../server/HttpRequest';
+
+export class SimpleCredentialsExtractor extends CredentialsExtractor {
+  public async canHandle(): Promise<void> {
+    return undefined;
+  }
+
+  public async handle(input: HttpRequest): Promise<Credentials> {
+    if (input.headers.authorization) {
+      return { webID: input.headers.authorization };
+    }
+    return undefined;
+  }
+}
diff --git a/src/authorization/SimpleAuthorizer.ts b/src/authorization/SimpleAuthorizer.ts
new file mode 100644
index 000000000..7de480769
--- /dev/null
+++ b/src/authorization/SimpleAuthorizer.ts
@@ -0,0 +1,14 @@
+import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError';
+import { Authorizer, AuthorizerArgs } from './Authorizer';
+
+export class SimpleAuthorizer extends Authorizer {
+  public async canHandle(input: AuthorizerArgs): Promise<void> {
+    if (!input.identifier || !input.permissions) {
+      throw new UnsupportedHttpError('Authorizer requires an identifier and permissions.');
+    }
+  }
+
+  public async handle(): Promise<void> {
+    return undefined;
+  }
+}
diff --git a/src/ldp/AuthenticatedLdpHandler.ts b/src/ldp/AuthenticatedLdpHandler.ts
index f5c626a85..57bcf5619 100644
--- a/src/ldp/AuthenticatedLdpHandler.ts
+++ b/src/ldp/AuthenticatedLdpHandler.ts
@@ -9,6 +9,7 @@ import { OperationHandler } from './operations/OperationHandler';
 import { PermissionSet } from './permissions/PermissionSet';
 import { PermissionsExtractor } from './permissions/PermissionsExtractor';
 import { RequestParser } from './http/RequestParser';
+import { ResponseDescription } from './operations/ResponseDescription';
 import { ResponseWriter } from './http/ResponseWriter';
 
 /**
@@ -87,15 +88,15 @@ export class AuthenticatedLdpHandler extends HttpHandler {
    */
   public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise<void> {
     let err: Error;
-    let operation: Operation;
+    let description: ResponseDescription;
 
     try {
-      operation = await this.runHandlers(input.request);
+      description = await this.runHandlers(input.request);
     } catch (error) {
       err = error;
     }
 
-    const writeData = { response: input.response, operation, error: err };
+    const writeData = { response: input.response, description, error: err };
 
     return this.responseWriter.handleSafe(writeData);
   }
@@ -107,13 +108,11 @@ export class AuthenticatedLdpHandler extends HttpHandler {
    *
    * @returns A promise resolving to the generated Operation.
    */
-  private async runHandlers(request: HttpRequest): Promise<Operation> {
+  private async runHandlers(request: HttpRequest): Promise<ResponseDescription> {
     const op: Operation = await this.requestParser.handleSafe(request);
     const credentials: Credentials = await this.credentialsExtractor.handleSafe(request);
     const permissions: PermissionSet = await this.permissionsExtractor.handleSafe(op);
     await this.authorizer.handleSafe({ credentials, identifier: op.target, permissions });
-    await this.operationHandler.handleSafe(op);
-
-    return op;
+    return this.operationHandler.handleSafe(op);
   }
 }
diff --git a/src/ldp/http/ResponseWriter.ts b/src/ldp/http/ResponseWriter.ts
index c6e9769de..ac5bed3d8 100644
--- a/src/ldp/http/ResponseWriter.ts
+++ b/src/ldp/http/ResponseWriter.ts
@@ -1,9 +1,9 @@
 import { AsyncHandler } from '../../util/AsyncHandler';
 import { HttpResponse } from '../../server/HttpResponse';
-import { Operation } from '../operations/Operation';
+import { ResponseDescription } from '../operations/ResponseDescription';
 
 /**
  * Writes to the HttpResponse.
  * Response depends on the operation result and potentially which errors was thrown.
  */
-export type ResponseWriter = AsyncHandler<{ response: HttpResponse; operation: Operation; error?: Error }>;
+export abstract class ResponseWriter extends AsyncHandler<{ response: HttpResponse; description?: ResponseDescription; error?: Error }> {}
diff --git a/src/ldp/http/SimpleResponseWriter.ts b/src/ldp/http/SimpleResponseWriter.ts
new file mode 100644
index 000000000..3308c100d
--- /dev/null
+++ b/src/ldp/http/SimpleResponseWriter.ts
@@ -0,0 +1,39 @@
+import { HttpError } from '../../util/errors/HttpError';
+import { HttpResponse } from '../../server/HttpResponse';
+import { ResponseDescription } from '../operations/ResponseDescription';
+import { ResponseWriter } from './ResponseWriter';
+import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
+
+export class SimpleResponseWriter extends ResponseWriter {
+  public async canHandle(input: { response: HttpResponse; description?: ResponseDescription; error?: Error }): Promise<void> {
+    if (!input.description && !input.error) {
+      throw new UnsupportedHttpError('Either a description or an error is required for output.');
+    }
+  }
+
+  public async handle(input: { response: HttpResponse; description?: ResponseDescription; error?: Error }): Promise<void> {
+    if (input.description) {
+      input.response.setHeader('location', input.description.identifier.path);
+      if (input.description.body) {
+        if (input.description.body.metadata.contentType) {
+          input.response.setHeader('content-type', input.description.body.metadata.contentType);
+        }
+        input.description.body.data.pipe(input.response);
+      }
+
+      input.response.writeHead(200);
+
+      if (!input.description.body) {
+        // If there is an input body the response will end once the input stream ends
+        input.response.end();
+      }
+    } else {
+      let code = 500;
+      if (input.error instanceof HttpError) {
+        code = input.error.statusCode;
+      }
+      input.response.writeHead(code);
+      input.response.end(`${input.error.name}: ${input.error.message}\n${input.error.stack}`);
+    }
+  }
+}
diff --git a/src/ldp/operations/OperationHandler.ts b/src/ldp/operations/OperationHandler.ts
index 405be2f10..b58ddf3a2 100644
--- a/src/ldp/operations/OperationHandler.ts
+++ b/src/ldp/operations/OperationHandler.ts
@@ -1,7 +1,8 @@
 import { AsyncHandler } from '../../util/AsyncHandler';
 import { Operation } from './Operation';
+import { ResponseDescription } from './ResponseDescription';
 
 /**
  * Handler for a specific operation type.
  */
-export abstract class OperationHandler extends AsyncHandler<Operation> {}
+export abstract class OperationHandler extends AsyncHandler<Operation, ResponseDescription> {}
diff --git a/src/ldp/operations/ResponseDescription.ts b/src/ldp/operations/ResponseDescription.ts
new file mode 100644
index 000000000..3958fdc2c
--- /dev/null
+++ b/src/ldp/operations/ResponseDescription.ts
@@ -0,0 +1,10 @@
+import { Representation } from '../representation/Representation';
+import { ResourceIdentifier } from '../representation/ResourceIdentifier';
+
+/**
+ * The result of executing an operation.
+ */
+export interface ResponseDescription {
+  identifier: ResourceIdentifier;
+  body?: Representation;
+}
diff --git a/src/ldp/operations/SimpleDeleteOperationHandler.ts b/src/ldp/operations/SimpleDeleteOperationHandler.ts
new file mode 100644
index 000000000..fd36f5323
--- /dev/null
+++ b/src/ldp/operations/SimpleDeleteOperationHandler.ts
@@ -0,0 +1,25 @@
+import { Operation } from './Operation';
+import { OperationHandler } from './OperationHandler';
+import { ResourceStore } from '../../storage/ResourceStore';
+import { ResponseDescription } from './ResponseDescription';
+import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
+
+export class SimpleDeleteOperationHandler extends OperationHandler {
+  private readonly store: ResourceStore;
+
+  public constructor(store: ResourceStore) {
+    super();
+    this.store = store;
+  }
+
+  public async canHandle(input: Operation): Promise<void> {
+    if (input.method !== 'DELETE') {
+      throw new UnsupportedHttpError('This handler only supports DELETE operations.');
+    }
+  }
+
+  public async handle(input: Operation): Promise<ResponseDescription> {
+    await this.store.deleteResource(input.target);
+    return { identifier: input.target };
+  }
+}
diff --git a/src/ldp/operations/SimpleGetOperationHandler.ts b/src/ldp/operations/SimpleGetOperationHandler.ts
new file mode 100644
index 000000000..1b6abdd50
--- /dev/null
+++ b/src/ldp/operations/SimpleGetOperationHandler.ts
@@ -0,0 +1,25 @@
+import { Operation } from './Operation';
+import { OperationHandler } from './OperationHandler';
+import { ResourceStore } from '../../storage/ResourceStore';
+import { ResponseDescription } from './ResponseDescription';
+import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
+
+export class SimpleGetOperationHandler extends OperationHandler {
+  private readonly store: ResourceStore;
+
+  public constructor(store: ResourceStore) {
+    super();
+    this.store = store;
+  }
+
+  public async canHandle(input: Operation): Promise<void> {
+    if (input.method !== 'GET') {
+      throw new UnsupportedHttpError('This handler only supports GET operations.');
+    }
+  }
+
+  public async handle(input: Operation): Promise<ResponseDescription> {
+    const body = await this.store.getRepresentation(input.target, input.preferences);
+    return { identifier: input.target, body };
+  }
+}
diff --git a/src/ldp/operations/SimplePostOperationHandler.ts b/src/ldp/operations/SimplePostOperationHandler.ts
new file mode 100644
index 000000000..f375a77d1
--- /dev/null
+++ b/src/ldp/operations/SimplePostOperationHandler.ts
@@ -0,0 +1,28 @@
+import { Operation } from './Operation';
+import { OperationHandler } from './OperationHandler';
+import { ResourceStore } from '../../storage/ResourceStore';
+import { ResponseDescription } from './ResponseDescription';
+import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
+
+export class SimplePostOperationHandler extends OperationHandler {
+  private readonly store: ResourceStore;
+
+  public constructor(store: ResourceStore) {
+    super();
+    this.store = store;
+  }
+
+  public async canHandle(input: Operation): Promise<void> {
+    if (input.method !== 'POST') {
+      throw new UnsupportedHttpError('This handler only supports POST operations.');
+    }
+    if (!input.body) {
+      throw new UnsupportedHttpError('POST operations require a body.');
+    }
+  }
+
+  public async handle(input: Operation): Promise<ResponseDescription> {
+    const identifier = await this.store.addResource(input.target, input.body);
+    return { identifier };
+  }
+}
diff --git a/src/ldp/permissions/SimplePermissionsExtractor.ts b/src/ldp/permissions/SimplePermissionsExtractor.ts
new file mode 100644
index 000000000..6da507987
--- /dev/null
+++ b/src/ldp/permissions/SimplePermissionsExtractor.ts
@@ -0,0 +1,18 @@
+import { Operation } from '../operations/Operation';
+import { PermissionSet } from './PermissionSet';
+import { PermissionsExtractor } from './PermissionsExtractor';
+
+export class SimplePermissionsExtractor extends PermissionsExtractor {
+  public async canHandle(): Promise<void> {
+    return undefined;
+  }
+
+  public async handle(input: Operation): Promise<PermissionSet> {
+    return {
+      read: input.method === 'GET',
+      append: false,
+      write: input.method === 'POST' || input.method === 'PUT',
+      delete: input.method === 'DELETE',
+    };
+  }
+}
diff --git a/src/server/HttpResponse.ts b/src/server/HttpResponse.ts
index 09174e542..ab6829c5f 100644
--- a/src/server/HttpResponse.ts
+++ b/src/server/HttpResponse.ts
@@ -1,6 +1,6 @@
-import { OutgoingMessage } from 'http';
+import { ServerResponse } from 'http';
 
 /**
  * An outgoing HTTP response;
  */
-export type HttpResponse = OutgoingMessage;
+export type HttpResponse = ServerResponse;
diff --git a/src/storage/SimpleResourceStore.ts b/src/storage/SimpleResourceStore.ts
new file mode 100644
index 000000000..b1855ed36
--- /dev/null
+++ b/src/storage/SimpleResourceStore.ts
@@ -0,0 +1,89 @@
+import arrayifyStream from 'arrayify-stream';
+import { BinaryRepresentation } from '../ldp/representation/BinaryRepresentation';
+import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
+import { Quad } from 'rdf-js';
+import { QuadRepresentation } from '../ldp/representation/QuadRepresentation';
+import { Readable } from 'stream';
+import { Representation } from '../ldp/representation/Representation';
+import { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
+import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import { ResourceStore } from './ResourceStore';
+import streamifyArray from 'streamify-array';
+import { StreamWriter } from 'n3';
+import { UnsupportedMediaTypeHttpError } from '../util/errors/UnsupportedMediaTypeHttpError';
+
+export class SimpleResourceStore implements ResourceStore {
+  private readonly store: { [id: string]: Quad[] } = { '': []};
+  private readonly base: string;
+  private index = 0;
+
+  public constructor(base: string) {
+    this.base = base;
+  }
+
+  public async addResource(container: ResourceIdentifier, representation: Representation): Promise<ResourceIdentifier> {
+    const containerPath = this.parseIdentifier(container);
+    const newPath = `${containerPath}/${this.index}`;
+    this.index += 1;
+    this.store[newPath] = await this.parseRepresentation(representation);
+    return { path: `${this.base}${newPath}` };
+  }
+
+  public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
+    const path = this.parseIdentifier(identifier);
+    // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+    delete this.store[path];
+  }
+
+  public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences): Promise<Representation> {
+    const path = this.parseIdentifier(identifier);
+    return this.generateRepresentation(this.store[path], preferences);
+  }
+
+  public async modifyResource(): Promise<void> {
+    throw new Error('Not supported.');
+  }
+
+  public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
+    const path = this.parseIdentifier(identifier);
+    this.store[path] = await this.parseRepresentation(representation);
+  }
+
+  private parseIdentifier(identifier: ResourceIdentifier): string {
+    const path = identifier.path.slice(this.base.length);
+    if (!this.store[path] || !identifier.path.startsWith(this.base)) {
+      throw new NotFoundHttpError();
+    }
+    return path;
+  }
+
+  private async parseRepresentation(representation: Representation): Promise<Quad[]> {
+    if (representation.dataType !== 'quad') {
+      throw new UnsupportedMediaTypeHttpError('SimpleResourceStore only supports quad representations.');
+    }
+    return arrayifyStream(representation.data);
+  }
+
+  private generateRepresentation(data: Quad[], preferences: RepresentationPreferences): Representation {
+    if (preferences.type && preferences.type.some((preference): boolean => preference.value.includes('text/turtle'))) {
+      return this.generateBinaryRepresentation(data);
+    }
+    return this.generateQuadRepresentation(data);
+  }
+
+  private generateBinaryRepresentation(data: Quad[]): BinaryRepresentation {
+    return {
+      dataType: 'binary',
+      data: streamifyArray(data).pipe(new StreamWriter({ format: 'text/turtle' })) as unknown as Readable,
+      metadata: { raw: [], profiles: [], contentType: 'text/turtle' },
+    };
+  }
+
+  private generateQuadRepresentation(data: Quad[]): QuadRepresentation {
+    return {
+      dataType: 'quad',
+      data: streamifyArray(data),
+      metadata: { raw: [], profiles: []},
+    };
+  }
+}
diff --git a/src/util/errors/NotFoundHttpError.ts b/src/util/errors/NotFoundHttpError.ts
new file mode 100644
index 000000000..276d2c1ed
--- /dev/null
+++ b/src/util/errors/NotFoundHttpError.ts
@@ -0,0 +1,7 @@
+import { HttpError } from './HttpError';
+
+export class NotFoundHttpError extends HttpError {
+  public constructor(message?: string) {
+    super(404, 'NotFoundHttpError', message);
+  }
+}


Test patch:
diff --git a/test/integration/AuthenticatedLdpHandler.test.ts b/test/integration/AuthenticatedLdpHandler.test.ts
new file mode 100644
index 000000000..c87169b3f
--- /dev/null
+++ b/test/integration/AuthenticatedLdpHandler.test.ts
@@ -0,0 +1,143 @@
+import { AuthenticatedLdpHandler } from '../../src/ldp/AuthenticatedLdpHandler';
+import { CompositeAsyncHandler } from '../../src/util/CompositeAsyncHandler';
+import { EventEmitter } from 'events';
+import { HttpRequest } from '../../src/server/HttpRequest';
+import { Operation } from '../../src/ldp/operations/Operation';
+import { ResponseDescription } from '../../src/ldp/operations/ResponseDescription';
+import { SimpleAuthorizer } from '../../src/authorization/SimpleAuthorizer';
+import { SimpleBodyParser } from '../../src/ldp/http/SimpleBodyParser';
+import { SimpleCredentialsExtractor } from '../../src/authentication/SimpleCredentialsExtractor';
+import { SimpleDeleteOperationHandler } from '../../src/ldp/operations/SimpleDeleteOperationHandler';
+import { SimpleGetOperationHandler } from '../../src/ldp/operations/SimpleGetOperationHandler';
+import { SimplePermissionsExtractor } from '../../src/ldp/permissions/SimplePermissionsExtractor';
+import { SimplePostOperationHandler } from '../../src/ldp/operations/SimplePostOperationHandler';
+import { SimplePreferenceParser } from '../../src/ldp/http/SimplePreferenceParser';
+import { SimpleRequestParser } from '../../src/ldp/http/SimpleRequestParser';
+import { SimpleResourceStore } from '../../src/storage/SimpleResourceStore';
+import { SimpleResponseWriter } from '../../src/ldp/http/SimpleResponseWriter';
+import { SimpleTargetExtractor } from '../../src/ldp/http/SimpleTargetExtractor';
+import streamifyArray from 'streamify-array';
+import { createResponse, MockResponse } from 'node-mocks-http';
+
+describe('An AuthenticatedLdpHandler with instantiated handlers', (): void => {
+  let handler: AuthenticatedLdpHandler;
+
+  beforeEach(async(): Promise<void> => {
+    const requestParser = new SimpleRequestParser({
+      targetExtractor: new SimpleTargetExtractor(),
+      preferenceParser: new SimplePreferenceParser(),
+      bodyParser: new SimpleBodyParser(),
+    });
+
+    const credentialsExtractor = new SimpleCredentialsExtractor();
+    const permissionsExtractor = new SimplePermissionsExtractor();
+    const authorizer = new SimpleAuthorizer();
+
+    const store = new SimpleResourceStore('http://test.com/');
+    const operationHandler = new CompositeAsyncHandler<Operation, ResponseDescription>([
+      new SimpleGetOperationHandler(store),
+      new SimplePostOperationHandler(store),
+      new SimpleDeleteOperationHandler(store),
+    ]);
+
+    const responseWriter = new SimpleResponseWriter();
+
+    handler = new AuthenticatedLdpHandler({
+      requestParser,
+      credentialsExtractor,
+      permissionsExtractor,
+      authorizer,
+      operationHandler,
+      responseWriter,
+    });
+  });
+
+  it('can add, read and delete data based on incoming requests.', async(): Promise<void> => {
+    // POST
+    let request = streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]) as HttpRequest;
+    request.url = 'http://test.com/';
+    request.method = 'POST';
+    request.headers = {
+      'content-type': 'text/turtle',
+    };
+    let response: MockResponse<any> = createResponse({ eventEmitter: EventEmitter });
+
+    let id;
+    let endPromise = new Promise((resolve): void => {
+      response.on('end', (): void => {
+        expect(response._isEndCalled()).toBeTruthy();
+        expect(response.statusCode).toBe(200);
+        expect(response._getData()).toHaveLength(0);
+        id = response._getHeaders().location;
+        expect(id).toContain(request.url);
+        resolve();
+      });
+    });
+
+    await handler.handleSafe({ request, response });
+    await endPromise;
+
+    // GET
+    request = {} as HttpRequest;
+    request.url = id;
+    request.method = 'GET';
+    request.headers = {
+      accept: 'text/turtle',
+    };
+    response = createResponse({ eventEmitter: EventEmitter });
+
+    endPromise = new Promise((resolve): void => {
+      response.on('end', (): void => {
+        expect(response._isEndCalled()).toBeTruthy();
+        expect(response.statusCode).toBe(200);
+        expect(response._getData()).toContain('<http://test.com/s> <http://test.com/p> <http://test.com/o>.');
+        expect(response._getHeaders().location).toBe(request.url);
+        resolve();
+      });
+    });
+
+    await handler.handleSafe({ request, response });
+    await endPromise;
+
+    // DELETE
+    request = {} as HttpRequest;
+    request.url = id;
+    request.method = 'DELETE';
+    request.headers = {};
+    response = createResponse({ eventEmitter: EventEmitter });
+
+    endPromise = new Promise((resolve): void => {
+      response.on('end', (): void => {
+        expect(response._isEndCalled()).toBeTruthy();
+        expect(response.statusCode).toBe(200);
+        expect(response._getData()).toHaveLength(0);
+        expect(response._getHeaders().location).toBe(request.url);
+        resolve();
+      });
+    });
+
+    await handler.handleSafe({ request, response });
+    await endPromise;
+
+    // GET
+    request = {} as HttpRequest;
+    request.url = id;
+    request.method = 'GET';
+    request.headers = {
+      accept: 'text/turtle',
+    };
+    response = createResponse({ eventEmitter: EventEmitter });
+
+    endPromise = new Promise((resolve): void => {
+      response.on('end', (): void => {
+        expect(response._isEndCalled()).toBeTruthy();
+        expect(response.statusCode).toBe(404);
+        expect(response._getData()).toContain('NotFoundHttpError');
+        resolve();
+      });
+    });
+
+    await handler.handleSafe({ request, response });
+    await endPromise;
+  });
+});
diff --git a/test/unit/authentication/SimpleCredentialsExtractor.test.ts b/test/unit/authentication/SimpleCredentialsExtractor.test.ts
new file mode 100644
index 000000000..6a59dff95
--- /dev/null
+++ b/test/unit/authentication/SimpleCredentialsExtractor.test.ts
@@ -0,0 +1,19 @@
+import { HttpRequest } from '../../../src/server/HttpRequest';
+import { SimpleCredentialsExtractor } from '../../../src/authentication/SimpleCredentialsExtractor';
+
+describe('A SimpleCredentialsExtractor', (): void => {
+  const extractor = new SimpleCredentialsExtractor();
+
+  it('can handle all input.', async(): Promise<void> => {
+    await expect(extractor.canHandle()).resolves.toBeUndefined();
+  });
+
+  it('returns undefined if there is no input.', async(): Promise<void> => {
+    await expect(extractor.handle({ headers: {}} as HttpRequest)).resolves.toBeUndefined();
+  });
+
+  it('returns the authorization header as webID if there is one.', async(): Promise<void> => {
+    await expect(extractor.handle({ headers: { authorization: 'test' }} as HttpRequest))
+      .resolves.toEqual({ webID: 'test' });
+  });
+});
diff --git a/test/unit/authorization/SimpleAuthorizer.test.ts b/test/unit/authorization/SimpleAuthorizer.test.ts
new file mode 100644
index 000000000..a8344f776
--- /dev/null
+++ b/test/unit/authorization/SimpleAuthorizer.test.ts
@@ -0,0 +1,17 @@
+import { AuthorizerArgs } from '../../../src/authorization/Authorizer';
+import { SimpleAuthorizer } from '../../../src/authorization/SimpleAuthorizer';
+import { UnsupportedHttpError } from '../../../src/util/errors/UnsupportedHttpError';
+
+describe('A SimpleAuthorizer', (): void => {
+  const authorizer = new SimpleAuthorizer();
+
+  it('requires input to have an identifier and permissions.', async(): Promise<void> => {
+    await expect(authorizer.canHandle({ identifier: {}, permissions: {}} as AuthorizerArgs)).resolves.toBeUndefined();
+    await expect(authorizer.canHandle({ identifier: {}} as AuthorizerArgs)).rejects.toThrow(UnsupportedHttpError);
+    await expect(authorizer.canHandle({ permissions: {}} as AuthorizerArgs)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('always returns undefined.', async(): Promise<void> => {
+    await expect(authorizer.handle()).resolves.toBeUndefined();
+  });
+});
diff --git a/test/unit/ldp/AuthenticatedLdpHandler.test.ts b/test/unit/ldp/AuthenticatedLdpHandler.test.ts
index 02bdec362..efa1d61a5 100644
--- a/test/unit/ldp/AuthenticatedLdpHandler.test.ts
+++ b/test/unit/ldp/AuthenticatedLdpHandler.test.ts
@@ -44,7 +44,7 @@ describe('An AuthenticatedLdpHandler', (): void => {
 
     await expect(handler.handle({ request: 'request' as any, response: 'response' as any })).resolves.toEqual('response');
     expect(responseFn).toHaveBeenCalledTimes(1);
-    expect(responseFn).toHaveBeenLastCalledWith({ response: 'response', operation: 'parser' as any });
+    expect(responseFn).toHaveBeenLastCalledWith({ response: 'response', description: 'operation' as any });
   });
 
   it('sends an error to the output if a handler does not support the input.', async(): Promise<void> => {
diff --git a/test/unit/ldp/http/SimpleResponseWriter.test.ts b/test/unit/ldp/http/SimpleResponseWriter.test.ts
new file mode 100644
index 000000000..c2679fdeb
--- /dev/null
+++ b/test/unit/ldp/http/SimpleResponseWriter.test.ts
@@ -0,0 +1,87 @@
+import { EventEmitter } from 'events';
+import { Quad } from 'rdf-js';
+import { ResponseDescription } from '../../../../src/ldp/operations/ResponseDescription';
+import { SimpleResponseWriter } from '../../../../src/ldp/http/SimpleResponseWriter';
+import streamifyArray from 'streamify-array';
+import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+import { createResponse, MockResponse } from 'node-mocks-http';
+
+describe('A SimpleResponseWriter', (): void => {
+  const writer = new SimpleResponseWriter();
+  let response: MockResponse<any>;
+
+  beforeEach(async(): Promise<void> => {
+    response = createResponse({ eventEmitter: EventEmitter });
+  });
+
+  it('can handle input that has at least a description or an error.', async(): Promise<void> => {
+    await expect(writer.canHandle({ response, description: {} as ResponseDescription })).resolves.toBeUndefined();
+    await expect(writer.canHandle({ response, error: {} as Error })).resolves.toBeUndefined();
+    await expect(writer.canHandle({ response })).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('responds with status code 200 and a location header if there is a description.', async(): Promise<void> => {
+    await writer.handle({ response, description: { identifier: { path: 'path' }}});
+    expect(response._isEndCalled()).toBeTruthy();
+    expect(response._getStatusCode()).toBe(200);
+    expect(response._getHeaders()).toMatchObject({ location: 'path' });
+  });
+
+  it('responds with a body if the description has a body.', async(done): Promise<void> => {
+    const body = {
+      data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]),
+      dataType: 'binary',
+      metadata: {
+        raw: [] as Quad[],
+        profiles: [] as string[],
+      },
+    };
+
+    response.on('end', (): void => {
+      expect(response._isEndCalled()).toBeTruthy();
+      expect(response._getStatusCode()).toBe(200);
+      expect(response._getHeaders()).toMatchObject({ location: 'path' });
+      expect(response._getData()).toEqual('<http://test.com/s> <http://test.com/p> <http://test.com/o>.');
+      done();
+    });
+
+    await writer.handle({ response, description: { identifier: { path: 'path' }, body }});
+  });
+
+  it('responds with a content-type if the metadata has it.', async(done): Promise<void> => {
+    const body = {
+      data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]),
+      dataType: 'binary',
+      metadata: {
+        raw: [] as Quad[],
+        profiles: [] as string[],
+        contentType: 'text/turtle',
+      },
+    };
+
+    response.on('end', (): void => {
+      expect(response._isEndCalled()).toBeTruthy();
+      expect(response._getStatusCode()).toBe(200);
+      expect(response._getHeaders()).toMatchObject({ location: 'path', 'content-type': 'text/turtle' });
+      expect(response._getData()).toEqual('<http://test.com/s> <http://test.com/p> <http://test.com/o>.');
+      done();
+    });
+
+    await writer.handle({ response, description: { identifier: { path: 'path' }, body }});
+  });
+
+  it('responds with 500 if an error if there is an error.', async(): Promise<void> => {
+    await writer.handle({ response, error: new Error('error') });
+    expect(response._isEndCalled()).toBeTruthy();
+    expect(response._getStatusCode()).toBe(500);
+    expect(response._getData()).toMatch('Error: error');
+  });
+
+  it('responds with the given statuscode if there is an HttpError.', async(): Promise<void> => {
+    const error = new UnsupportedHttpError('error');
+    await writer.handle({ response, error });
+    expect(response._isEndCalled()).toBeTruthy();
+    expect(response._getStatusCode()).toBe(error.statusCode);
+    expect(response._getData()).toMatch('UnsupportedHttpError: error');
+  });
+});
diff --git a/test/unit/ldp/operations/SimpleDeleteOperationHandler.test.ts b/test/unit/ldp/operations/SimpleDeleteOperationHandler.test.ts
new file mode 100644
index 000000000..9d9ebe7b2
--- /dev/null
+++ b/test/unit/ldp/operations/SimpleDeleteOperationHandler.test.ts
@@ -0,0 +1,24 @@
+import { Operation } from '../../../../src/ldp/operations/Operation';
+import { ResourceStore } from '../../../../src/storage/ResourceStore';
+import { SimpleDeleteOperationHandler } from '../../../../src/ldp/operations/SimpleDeleteOperationHandler';
+import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+
+describe('A SimpleDeleteOperationHandler', (): void => {
+  const store = {} as unknown as ResourceStore;
+  const handler = new SimpleDeleteOperationHandler(store);
+  beforeEach(async(): Promise<void> => {
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    store.deleteResource = jest.fn(async(): Promise<void> => {});
+  });
+
+  it('only supports GET operations.', async(): Promise<void> => {
+    await expect(handler.canHandle({ method: 'DELETE' } as Operation)).resolves.toBeUndefined();
+    await expect(handler.canHandle({ method: 'GET' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('deletes the resource from the store and returns its identifier.', async(): Promise<void> => {
+    await expect(handler.handle({ target: { path: 'url' }} as Operation)).resolves.toEqual({ identifier: { path: 'url' }});
+    expect(store.deleteResource).toHaveBeenCalledTimes(1);
+    expect(store.deleteResource).toHaveBeenLastCalledWith({ path: 'url' });
+  });
+});
diff --git a/test/unit/ldp/operations/SimpleGetOperationHandler.test.ts b/test/unit/ldp/operations/SimpleGetOperationHandler.test.ts
new file mode 100644
index 000000000..ec334032e
--- /dev/null
+++ b/test/unit/ldp/operations/SimpleGetOperationHandler.test.ts
@@ -0,0 +1,23 @@
+import { Operation } from '../../../../src/ldp/operations/Operation';
+import { Representation } from '../../../../src/ldp/representation/Representation';
+import { ResourceStore } from '../../../../src/storage/ResourceStore';
+import { SimpleGetOperationHandler } from '../../../../src/ldp/operations/SimpleGetOperationHandler';
+import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+
+describe('A SimpleGetOperationHandler', (): void => {
+  const store = {
+    getRepresentation: async(): Promise<Representation> => ({ dataType: 'quad' } as Representation),
+  } as unknown as ResourceStore;
+  const handler = new SimpleGetOperationHandler(store);
+
+  it('only supports GET operations.', async(): Promise<void> => {
+    await expect(handler.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined();
+    await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('returns the representation from the store with the input identifier.', async(): Promise<void> => {
+    await expect(handler.handle({ target: { path: 'url' }} as Operation)).resolves.toEqual(
+      { identifier: { path: 'url' }, body: { dataType: 'quad' }},
+    );
+  });
+});
diff --git a/test/unit/ldp/operations/SimplePostOperationHandler.test.ts b/test/unit/ldp/operations/SimplePostOperationHandler.test.ts
new file mode 100644
index 000000000..733bd024f
--- /dev/null
+++ b/test/unit/ldp/operations/SimplePostOperationHandler.test.ts
@@ -0,0 +1,22 @@
+import { Operation } from '../../../../src/ldp/operations/Operation';
+import { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
+import { ResourceStore } from '../../../../src/storage/ResourceStore';
+import { SimplePostOperationHandler } from '../../../../src/ldp/operations/SimplePostOperationHandler';
+import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+
+describe('A SimplePostOperationHandler', (): void => {
+  const store = {
+    addResource: async(): Promise<ResourceIdentifier> => ({ path: 'newPath' } as ResourceIdentifier),
+  } as unknown as ResourceStore;
+  const handler = new SimplePostOperationHandler(store);
+
+  it('only supports POST operations with a body.', async(): Promise<void> => {
+    await expect(handler.canHandle({ method: 'POST', body: { dataType: 'test' }} as Operation)).resolves.toBeUndefined();
+    await expect(handler.canHandle({ method: 'GET', body: { dataType: 'test' }} as Operation)).rejects.toThrow(UnsupportedHttpError);
+    await expect(handler.canHandle({ method: 'POST' } as Operation)).rejects.toThrow(UnsupportedHttpError);
+  });
+
+  it('adds the given representation to the store and returns the new identifier.', async(): Promise<void> => {
+    await expect(handler.handle({ method: 'POST', body: { dataType: 'test' }} as Operation)).resolves.toEqual({ identifier: { path: 'newPath' }});
+  });
+});
diff --git a/test/unit/ldp/permissions/SimplePermissionsExtractor.test.ts b/test/unit/ldp/permissions/SimplePermissionsExtractor.test.ts
new file mode 100644
index 000000000..ab68a6f52
--- /dev/null
+++ b/test/unit/ldp/permissions/SimplePermissionsExtractor.test.ts
@@ -0,0 +1,46 @@
+import { Operation } from '../../../../src/ldp/operations/Operation';
+import { SimplePermissionsExtractor } from '../../../../src/ldp/permissions/SimplePermissionsExtractor';
+
+describe('A SimplePermissionsExtractor', (): void => {
+  const extractor = new SimplePermissionsExtractor();
+
+  it('can handle all input.', async(): Promise<void> => {
+    await expect(extractor.canHandle()).resolves.toBeUndefined();
+  });
+
+  it('requires read for GET operations.', async(): Promise<void> => {
+    await expect(extractor.handle({ method: 'GET' } as Operation)).resolves.toEqual({
+      read: true,
+      append: false,
+      write: false,
+      delete: false,
+    });
+  });
+
+  it('requires write for POST operations.', async(): Promise<void> => {
+    await expect(extractor.handle({ method: 'POST' } as Operation)).resolves.toEqual({
+      read: false,
+      append: false,
+      write: true,
+      delete: false,
+    });
+  });
+
+  it('requires write for PUT operations.', async(): Promise<void> => {
+    await expect(extractor.handle({ method: 'PUT' } as Operation)).resolves.toEqual({
+      read: false,
+      append: false,
+      write: true,
+      delete: false,
+    });
+  });
+
+  it('requires delete for DELETE operations.', async(): Promise<void> => {
+    await expect(extractor.handle({ method: 'DELETE' } as Operation)).resolves.toEqual({
+      read: false,
+      append: false,
+      write: false,
+      delete: true,
+    });
+  });
+});
diff --git a/test/unit/storage/SimpleResourceStore.test.ts b/test/unit/storage/SimpleResourceStore.test.ts
new file mode 100644
index 000000000..2ece75f2f
--- /dev/null
+++ b/test/unit/storage/SimpleResourceStore.test.ts
@@ -0,0 +1,98 @@
+import arrayifyStream from 'arrayify-stream';
+import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
+import { QuadRepresentation } from '../../../src/ldp/representation/QuadRepresentation';
+import { Readable } from 'stream';
+import { SimpleResourceStore } from '../../../src/storage/SimpleResourceStore';
+import streamifyArray from 'streamify-array';
+import { UnsupportedMediaTypeHttpError } from '../../../src/util/errors/UnsupportedMediaTypeHttpError';
+import { namedNode, triple } from '@rdfjs/data-model';
+
+const base = 'http://test.com/';
+
+describe('A SimpleResourceStore', (): void => {
+  let store: SimpleResourceStore;
+  let representation: QuadRepresentation;
+  const quad = triple(
+    namedNode('http://test.com/s'),
+    namedNode('http://test.com/p'),
+    namedNode('http://test.com/o'),
+  );
+
+  beforeEach(async(): Promise<void> => {
+    store = new SimpleResourceStore(base);
+
+    representation = {
+      data: streamifyArray([ quad ]),
+      dataType: 'quad',
+      metadata: null,
+    };
+  });
+
+  it('errors if a resource was not found.', async(): Promise<void> => {
+    await expect(store.getRepresentation({ path: `${base}wrong` }, {})).rejects.toThrow(NotFoundHttpError);
+    await expect(store.addResource({ path: 'http://wrong.com/wrong' }, null)).rejects.toThrow(NotFoundHttpError);
+    await expect(store.deleteResource({ path: 'wrong' })).rejects.toThrow(NotFoundHttpError);
+    await expect(store.setRepresentation({ path: 'http://wrong.com/' }, null)).rejects.toThrow(NotFoundHttpError);
+  });
+
+  it('errors when modifying resources.', async(): Promise<void> => {
+    await expect(store.modifyResource()).rejects.toThrow(Error);
+  });
+
+  it('errors for wrong input data types.', async(): Promise<void> => {
+    (representation as any).dataType = 'binary';
+    await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError);
+  });
+
+  it('can write and read data.', async(): Promise<void> => {
+    const identifier = await store.addResource({ path: base }, representation);
+    expect(identifier.path.startsWith(base)).toBeTruthy();
+    const result = await store.getRepresentation(identifier, {});
+    expect(result).toEqual({
+      dataType: 'quad',
+      data: expect.any(Readable),
+      metadata: {
+        profiles: [],
+        raw: [],
+      },
+    });
+    await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ quad ]);
+  });
+
+  it('can read binary data.', async(): Promise<void> => {
+    const identifier = await store.addResource({ path: base }, representation);
+    expect(identifier.path.startsWith(base)).toBeTruthy();
+    const result = await store.getRepresentation(identifier, { type: [{ value: 'text/turtle', weight: 1 }]});
+    expect(result).toEqual({
+      dataType: 'binary',
+      data: expect.any(Readable),
+      metadata: {
+        profiles: [],
+        raw: [],
+        contentType: 'text/turtle',
+      },
+    });
+    await expect(arrayifyStream(result.data)).resolves.toContain(
+      `<${quad.subject.value}> <${quad.predicate.value}> <${quad.object.value}>`,
+    );
+  });
+
+  it('can set data.', async(): Promise<void> => {
+    await store.setRepresentation({ path: base }, representation);
+    const result = await store.getRepresentation({ path: base }, {});
+    expect(result).toEqual({
+      dataType: 'quad',
+      data: expect.any(Readable),
+      metadata: {
+        profiles: [],
+        raw: [],
+      },
+    });
+    await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray([ quad ]);
+  });
+
+  it('can delete data.', async(): Promise<void> => {
+    await store.deleteResource({ path: base });
+    await expect(store.getRepresentation({ path: base }, {})).rejects.toThrow(NotFoundHttpError);
+  });
+});