Gruber

HTTP

The HTTP module is all about the integration with a platform's HTTP server. It creates a common standards-based layer to create agnostic servers and also provides nice abstractions for other parts of Gruber to consume.

The module is based around route definitions created with defineRoute. This pulls together a few standards to make a route definition that you can easily pass around.

import { defineRoute } from "gruber";

// A route is a first-class thing, it can easily be passed around and used
export const helloRoute = defineRoute({
  method: "GET",
  pathname: "/hello/:name",
  handler({ params }) {
    return new Response(`Hello, ${params.name}!`);
  },
});

Once you have a few routes, they can be passed to a FetchRouter to easily serve them.

import { FetchRouter } from "gruber";
import { helloRoute } from "./hello-route.ts";

const router = new FetchRouter({
  routes: [helloRoute],
});

Depending on the platform you're running on you can use the FetchRouter with the platform's HTTP server. See Deno or Node.js for more.

Other notable parts of the HTTP module are:

There is also the unstable AuthorizationService checking request authorization

Authorization

includesScope

Check whether a provided scope meets the requirement of the expected scope

The idea is that a parent scope contains all children scopes, recursively. So if you find all the parents of a given scope, you can test it against a scope that has been provided by a user.

For example user:books:read expands to:

  • user:books:read
  • user:books
  • user

So if any of those scopes are authorized, access can be granted.

includesScope("user:books:read", "user:books:read"); // true
includesScope("user:books", "user:books:read");      // true
includesScope("user", "user:books:read");            // true
includesScope("user", "user:podcasts");              // true
includesScope("user:books", "user:podcasts");        // false

Miscellaneous

Cors unstable

A development utility for apply CORS headers to a HTTP server using standard Request and Response objects.

This really should not be used in production, I built this with the intention that whatever reverse-proxy the app is deployed behind would manage these headers instead.

This implementation was adapted from expressjs/cors, mostly to modernise it and remove features that weren't needed for this development-intended class.

It will:

  • set Access-Control-Allow-Methods to all methods
  • mirror headers in Access-Control-Request-Headers from the request
  • properly set the Vary header for any request header that varies the response
  • set Access-Control-Allow-Origin based on the options.origins option, allowing the origin if it is in the array or if the array includes *
  • set Access-Control-Allow-Credentials if opted in through options.credentials
const cors = new Cors({
  origins: ['http://localhost:8080'],
  credentials: true
})

const request = new Request('http://localhost:3000/books/')
const response = Response.json({})

const result = cors.apply(request, response)

ServerSentEventMessage type

Represents a message in the Server-Sent Event protocol

// All fields are optional
const message = {
  comment: 'hello there',
  event: 'my-event',
  data: JSON.stringify({ lots: 'of', things: true }),
  id: 42,
  retry: 3600
}

ServerSentEventStream

Transforms server-sent message objects into strings for the client. more info

const data = [{ data: "hello there" }]

// Get a stream somehow, then pipe it through
const stream = ReadableStream.from<ServerSentEventMessage>(data)
  .pipeThrough(new ServerSentEventStream());

const response = new Response(stream, {
  headers: {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
  },
});

Routing

defineRoute

defineRoute is the way of specifying how your server handles a specific bit of web traffic. It returns the RouteDefinition which can be passed around and used in various places. Mainly it is passed to a FetchRouter to serve web requests.

export const helloRoute = defineRoute({
	method: "GET",
	pathname: "/hello/:name",
	handler({ request, url, params }) {
		return new Response(`Hello, ${params.name}!`);
	}
})

FetchRouter

FetchRouter is a web-native router for routes defined with defineRoute.

const routes = [defineRoute("..."), defineRoute("..."), defineRoute("...")];

const router = new FetchRouter({ routes });

All options to the FetchRouter constructor are optional and you can create a router without any options if you want.

routes are the route definitions you want the router to processes, the router will handle a request based on the first route that matches. So order is important.

errorHandler is called if a non-HTTPError or a 5xx HTTPError is thrown. It is called with the offending error and the request it is associated with.

NOTE: The errorHandler could do more in the future, like create it's own Response or mutate the existing response. This has not been designed and is left open to future development if it becomes important.

log is an unstable option to turn on HTTP logging, it can be a boolean or middleware function.

cors is an unstable option to apply a CORS instance to all requests

findMatches

Find each matching route in turn

let request = new Request('...')

for (const match of router.findMatches(request)) {
	// do something with the request and/or break the loop
}

getResponse

Process all routes and get a HTTP Response.

const response = router.getResponse(
	new Request('http://localhost/pathname')
)

NOTE: it would be nice to align this with the Fetch API fetch method signature.

handleError

Attempt to handle an error thrown from a route's handler, checking for well-known HTTPError instance or converting unknown errors into one. The HTTPError is then used to convert the error into a HTTP Response.

If the error is server-based it will trigger the FetchRouter's errorHandler.

processMatches

Take an iterator of route matches and convert them into a HTTP Response by executing the route's handler. It will return the first route to return a Response object or throw a HTTPError if no routes matched.

processRoute

Execute a route's handler to generate a HTTP Response

HTTPError

HTTPError

A custom Error subclass that represents an HTTP error to be returned to the user.

This allows routes to throw specific HTTP errors directly and FetchRouter knows how to handle them and turn them into HTTP Responses

You can use well-known errors like below, you can also pass a BodyInit to customise the response body.

throw HTTPError.badRequest()
throw HTTPError.unauthorized()
throw HTTPError.notFound()
throw HTTPError.internalServerError()
throw HTTPError.notImplemented()

// The plan is to add more error well-known codes as they are needed

You can also manually construct the error:

const teapot = new HTTPError(418, "I'm a teapot");

body

A custom body to send to the client

headers

Extra headers to send to the client

HTTPError.badRequest

400 Bad Request

throw HTTPError.badRequest()

HTTPError.internalServerError

500 Internal Server Error

throw HTTPError.internalServerError()

HTTPError.notFound

404 Not Found

throw HTTPError.notFound()

HTTPError.notImplemented

500 Not Implemented

throw HTTPError.notImplemented()

HTTPError.unauthorized

401 Unauthorized

throw HTTPError.unauthorized()

status

The HTTP status to return ~ status

statusText

The status text to return ~ statusText

toResponse

Convert the HTTPError into a HTTP Response object taking into account the status, statusText and headers fields on the error.

const error = new HTTPError(418, "I'm a teapot");

error.toResponse() // Response

Validation

getRequestBody unstable

Get and parse well-known request bodies based on the Content-Type header supplied


// Parse a application/x-www-form-urlencoded or multipart/form-data request
const formData = await getRequestBody(
  new Request('http://localhost:8000', { body: new FormData() })
)

// Parse a JSON request
const json = await getRequestBody(
  new Request('http://localhost:8000', {
    body: JSON.stringify({ hello: 'world' }),
    headers: { 'Content-Type': 'application/json' },
  })
)

assertRequestBody unstable

Validate the body of a request against a StandardSchema (including Gruber's own Structure).

This will throw nice HTTPError errors that are caught by gruber and sent along to the user.

const struct = Structure.object({ name: Structure.string() })

const body1 = await assertRequestBody(struct, new Request('…'))

NOTE — you need to await the function when passing a Request because parsing the body is asynchronous

or from a JavaScript value:

const body2 = assertRequestBody(struct, {})
const body3 = assertRequestBody(struct, new FormData())
const body3 = assertRequestBody(struct, new URLSearchParams())

you can use any StandardSchema library with this:

import { z } from 'zod'

const body4 = assertRequestBody(
  z.object({ name: z.string() }),
  { name: "Geoff Testington" }
)
debug
{
  "includesScope": {
    "entrypoint": "http/mod.ts",
    "id": "includesScope",
    "name": "includesScope",
    "content": "Check whether a provided scope meets the requirement of the expected scope\n\nThe idea is that a parent scope contains all children scopes, recursively.\nSo if you find all the parents of a given scope, you can test it against a scope that has been provided by a user.\n\nFor example `user:books:read` expands to:\n- `user:books:read`\n- `user:books`\n- `user`\n\nSo if any of those scopes are authorized, access can be granted.\n\n```js\nincludesScope(\"user:books:read\", \"user:books:read\"); // true\nincludesScope(\"user:books\", \"user:books:read\");      // true\nincludesScope(\"user\", \"user:books:read\");            // true\nincludesScope(\"user\", \"user:podcasts\");              // true\nincludesScope(\"user:books\", \"user:podcasts\");        // false\n```",
    "tags": {
      "group": "Authorization"
    },
    "children": {}
  },
  "Cors": {
    "entrypoint": "http/mod.ts",
    "id": "Cors",
    "name": "Cors",
    "content": "A development utility for apply CORS headers to a HTTP server using standard\n[Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)\nand [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects.\n\n> This really **should not** be used in production, I built this with the intention that\n> whatever reverse-proxy the app is deployed behind would manage these headers instead.\n\nThis implementation was adapted from [expressjs/cors](https://github.com/expressjs/cors),\nmostly to modernise it and remove features that weren't needed for this development-intended class.\n\nIt will:\n\n- set `Access-Control-Allow-Methods` to all methods\n- mirror headers in `Access-Control-Request-Headers` from the request\n- properly set the `Vary` header for any request header that varies the response\n- set `Access-Control-Allow-Origin` based on the `options.origins` option, allowing the origin if it is in the array or if the array includes `*`\n- set `Access-Control-Allow-Credentials` if opted in through `options.credentials`\n\n```js\nconst cors = new Cors({\n  origins: ['http://localhost:8080'],\n  credentials: true\n})\n\nconst request = new Request('http://localhost:3000/books/')\nconst response = Response.json({})\n\nconst result = cors.apply(request, response)\n```",
    "tags": {
      "unstable": "true",
      "group": "Miscellaneous"
    },
    "children": {}
  },
  "defineRoute": {
    "entrypoint": "http/mod.ts",
    "id": "defineRoute",
    "name": "defineRoute",
    "content": "`defineRoute` is the way of specifying how your server handles a specific bit of web traffic.\nIt returns the RouteDefinition which can be passed around and used in various places.\nMainly it is passed to a `FetchRouter` to serve web requests.\n\n```js\nexport const helloRoute = defineRoute({\n\tmethod: \"GET\",\n\tpathname: \"/hello/:name\",\n\thandler({ request, url, params }) {\n\t\treturn new Response(`Hello, ${params.name}!`);\n\t}\n})\n```",
    "tags": {
      "group": "Routing"
    },
    "children": {}
  },
  "FetchRouter": {
    "entrypoint": "http/mod.ts",
    "id": "FetchRouter",
    "name": "FetchRouter",
    "content": "`FetchRouter` is a web-native router for routes defined with `defineRoute`.\n\n```js\nconst routes = [defineRoute(\"...\"), defineRoute(\"...\"), defineRoute(\"...\")];\n\nconst router = new FetchRouter({ routes });\n```\n\nAll options to the `FetchRouter` constructor are optional\nand you can create a router without any options if you want.\n\n`routes` are the route definitions you want the router to processes,\nthe router will handle a request based on the first route that matches.\nSo order is important.\n\n`errorHandler` is called if a non-`HTTPError` or a 5xx `HTTPError` is thrown.\nIt is called with the offending error and the request it is associated with.\n\n> NOTE: The `errorHandler` could do more in the future,\n> like create it's own Response or mutate the existing response.\n> This has not been designed and is left open to future development if it becomes important.\n\n`log` is an **unstable** option to turn on HTTP logging, it can be a boolean or middleware function.\n\n`cors` is an **unstable** option to apply a [CORS](#cors) instance to all requests",
    "tags": {
      "group": "Routing"
    },
    "children": {
      "findMatches": {
        "id": "FetchRouter#findMatches",
        "name": "findMatches",
        "content": "Find each matching route in turn\n\n```js\nlet request = new Request('...')\n\nfor (const match of router.findMatches(request)) {\n\t// do something with the request and/or break the loop\n}\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "processMatches": {
        "id": "FetchRouter#processMatches",
        "name": "processMatches",
        "content": "Take an iterator of route matches and convert them into a HTTP Response\nby executing the route's handler.\nIt will return the first route to return a `Response` object\nor throw a `HTTPError` if no routes matched.",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "processRoute": {
        "id": "FetchRouter#processRoute",
        "name": "processRoute",
        "content": "Execute a route's handler to generate a HTTP `Response`",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "handleError": {
        "id": "FetchRouter#handleError",
        "name": "handleError",
        "content": "Attempt to handle an error thrown from a route's handler,\nchecking for well-known HTTPError instance or converting unknown errors into one.\nThe HTTPError is then used to convert the error into a HTTP `Response`.\n\nIf the error is server-based it will trigger the `FetchRouter`'s `errorHandler`.",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "getResponse": {
        "id": "FetchRouter#getResponse",
        "name": "getResponse",
        "content": "Process all routes and get a HTTP Response.\n\n```js\nconst response = router.getResponse(\n\tnew Request('http://localhost/pathname')\n)\n```\n\n> NOTE: it would be nice to align this with the Fetch API `fetch` method signature.",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      }
    }
  },
  "HTTPError": {
    "entrypoint": "http/mod.ts",
    "id": "HTTPError",
    "name": "HTTPError",
    "content": "A custom [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)\nsubclass that represents an HTTP error to be returned to the user.\n\nThis allows routes to throw specific HTTP errors directly and\n[FetchRouter](#fetchrouter) knows how to handle them and turn them into HTTP Responses\n\n\n\nYou can use well-known errors like below, you can also pass a [BodyInit](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#body) to customise the response body.\n\n```js\nthrow HTTPError.badRequest()\nthrow HTTPError.unauthorized()\nthrow HTTPError.notFound()\nthrow HTTPError.internalServerError()\nthrow HTTPError.notImplemented()\n\n// The plan is to add more error well-known codes as they are needed\n```\n\nYou can also manually construct the error:\n\n```js\nconst teapot = new HTTPError(418, \"I'm a teapot\");\n```",
    "tags": {
      "group": "HTTPError"
    },
    "children": {
      "status": {
        "id": "HTTPError#status",
        "name": "status",
        "content": "The HTTP status to return ~ [status](https://developer.mozilla.org/en-US/docs/Web/API/Response/status)",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "statusText": {
        "id": "HTTPError#statusText",
        "name": "statusText",
        "content": "The status text to return ~ [statusText](https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText)",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "body": {
        "id": "HTTPError#body",
        "name": "body",
        "content": "A custom body to send to the client",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "headers": {
        "id": "HTTPError#headers",
        "name": "headers",
        "content": "Extra headers to send to the client",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "toResponse": {
        "id": "HTTPError#toResponse",
        "name": "toResponse",
        "content": "Convert the HTTPError into a HTTP `Response` object\ntaking into account the `status`, `statusText` and `headers` fields on the error.\n\n```js\nconst error = new HTTPError(418, \"I'm a teapot\");\n\nerror.toResponse() // Response\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "HTTPError.badRequest": {
        "id": "HTTPError.badRequest",
        "name": "badRequest",
        "content": "[400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400)\n\n```js\nthrow HTTPError.badRequest()\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "HTTPError.unauthorized": {
        "id": "HTTPError.unauthorized",
        "name": "unauthorized",
        "content": "[401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401)\n\n```js\nthrow HTTPError.unauthorized()\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "HTTPError.notFound": {
        "id": "HTTPError.notFound",
        "name": "notFound",
        "content": "[404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\n```js\nthrow HTTPError.notFound()\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "HTTPError.internalServerError": {
        "id": "HTTPError.internalServerError",
        "name": "internalServerError",
        "content": "[500 Internal Server Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500)\n\n```js\nthrow HTTPError.internalServerError()\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      },
      "HTTPError.notImplemented": {
        "id": "HTTPError.notImplemented",
        "name": "notImplemented",
        "content": "[500 Not Implemented](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501)\n\n```js\nthrow HTTPError.notImplemented()\n```",
        "tags": {
          "group": "Miscellaneous"
        },
        "children": {}
      }
    }
  },
  "getRequestBody": {
    "entrypoint": "http/mod.ts",
    "id": "getRequestBody",
    "name": "getRequestBody",
    "content": "Get and parse well-known request bodies based on the Content-Type header supplied\n\n```js\n\n// Parse a application/x-www-form-urlencoded or multipart/form-data request\nconst formData = await getRequestBody(\n  new Request('http://localhost:8000', { body: new FormData() })\n)\n\n// Parse a JSON request\nconst json = await getRequestBody(\n  new Request('http://localhost:8000', {\n    body: JSON.stringify({ hello: 'world' }),\n    headers: { 'Content-Type': 'application/json' },\n  })\n)\n```",
    "tags": {
      "unstable": "true",
      "group": "Validation"
    },
    "children": {}
  },
  "assertRequestBody": {
    "entrypoint": "http/mod.ts",
    "id": "assertRequestBody",
    "name": "assertRequestBody",
    "content": "Validate the body of a request against a [StandardSchema](https://standardschema.dev/) (including Gruber's own Structure).\n\nThis will throw nice [HTTPError](#httperror) errors that are caught by gruber and sent along to the user.\n\n```js\nconst struct = Structure.object({ name: Structure.string() })\n\nconst body1 = await assertRequestBody(struct, new Request('…'))\n```\n\n> **NOTE** — you need to await the function when passing a `Request`\n> because parsing the body is asynchronous\n\nor from a JavaScript value:\n\n```js\nconst body2 = assertRequestBody(struct, { … })\nconst body3 = assertRequestBody(struct, new FormData(…))\nconst body3 = assertRequestBody(struct, new URLSearchParams(…))\n```\n\nyou can use any StandardSchema library with this:\n\n```js\nimport { z } from 'zod'\n\nconst body4 = assertRequestBody(\n  z.object({ name: z.string() }),\n  { name: \"Geoff Testington\" }\n)\n```",
    "tags": {
      "unstable": "true",
      "group": "Validation"
    },
    "children": {}
  },
  "ServerSentEventMessage": {
    "entrypoint": "http/mod.ts",
    "id": "ServerSentEventMessage",
    "name": "ServerSentEventMessage",
    "content": "Represents a message in the [Server-Sent Event protocol](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields)\n\n```js\n// All fields are optional\nconst message = {\n  comment: 'hello there',\n  event: 'my-event',\n  data: JSON.stringify({ lots: 'of', things: true }),\n  id: 42,\n  retry: 3600\n}\n```",
    "tags": {
      "group": "Miscellaneous",
      "type": "true"
    },
    "children": {}
  },
  "ServerSentEventStream": {
    "entrypoint": "http/mod.ts",
    "id": "ServerSentEventStream",
    "name": "ServerSentEventStream",
    "content": "Transforms server-sent message objects into strings for the client.\n[more info](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)\n\n```js\nconst data = [{ data: \"hello there\" }]\n\n// Get a stream somehow, then pipe it through\nconst stream = ReadableStream.from(data)\n  .pipeThrough(new ServerSentEventStream());\n\nconst response = new Response(stream, {\n  headers: {\n    \"content-type\": \"text/event-stream\",\n    \"cache-control\": \"no-cache\",\n  },\n});\n```",
    "tags": {
      "group": "Miscellaneous"
    },
    "children": {}
  }
}