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.
- Fetch API Request & Response objects
- URL objects
- URLPattern for route matching
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:
- CORS — a development utility for applying CORS headers
- ServerSentEventStream — for streaming Server-sent events responses using the Streams API
- HTTPError — a custom error subclass for throwing HTTP errors directly in routes
There is also the unstable AuthorizationService checking request 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 theoptions.origins
option, allowing the origin if it is in the array or if the array includes*
- set
Access-Control-Allow-Credentials
if opted in throughoptions.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
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": {} } }