Inspired on routing-controllers for use in Deno. Objective is to have a library that is as powerful as "routing-controllers", less verbose and with no decorator dependency. You can use grout with plain Deno handlers (and there is an adapter for oak).
I have happily used routing-controllers for several projects. However at some point it started to feel verbose and that it had the following drawbacks/limitations:
- Too many decorators
- Goes against the DRY principle, as in
get(@Param("id") id: number)
- Depends on metadata/reflection
I tried to craft a library as versatile as routing-controllers, that is less verbose and does not depend on decorators for metadata (instead using runtime type detection and convention over configuration).
To use simple import the "https://deno.land/x/grout/mod.ts" url. Added bonus: no need to import "reflect-metatada" or create a "tsconfig.js".
See file users.controller.ts
below. It will be a somewhat functional (albeit naïve) in-memory database of users. Below is a very simplified version of the controller used in tests. The controller declares REST routes via the name of the method and path parameters start with $
(as in delete_$id
).
Methods are defined according to the following grammar (_
transforms into /
, $parama
into :param
and $$ext
into .ext
).
METHOD_path_:param_new[$$ext]
Example usage:
// Define a type for users
type User = { id: number, name: string };
// This is the in-memory DB for users
const users: User[] = [
{ id: 0, name: "root" },
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
];
// Declare a controller
class UserController {
// DELETE /users/:id
delete_$id(id = -1) {
const i = users.findIndex(u => u.id === id);
if (i == -1) throw new Deno.errors.NotFound();
users.splice(i, 1);
return { id, status: "deleted" };
}
// GET /users
get() {
return users;
}
// GET /users/:id
get_$id(id = -1) {
const user = users.find(u => u.id === id);
if (!user) throw new Deno.errors.NotFound();
return user;
}
// PUT /users/:id
put_$id(id = 1, body: User) {
const user = users.find(u => u.id === id);
if (!user) throw new Deno.errors.NotFound();
Object.assign(user, body);
return { id, status: "created" };
}
}
A simple sever can be created as app.ts
:
// Create a controller instance and route traffic to it, it will
// return a Response object if it was intended for it
const users = new UserController();
Deno.serve({ port: 8000 }, (request: Request) => {
const response = handle(users, "/users", request);
if (response) return response;
// The router did not take the request, respond "Not Implemented"
return new Response("NOT IMPLEMENTED", { status: Status.NotImplemented });
});
console.log("Server is running on port 8000. Open http://localhost:8000/users/");
You can now open the browser at http://localhost:8000/users
. You will see a JSON document similar to:
[{"id":0,"name":"root"},{"id":1,"name":"John"},{"id":2,"name":"Jane"}]
If you open http://localhost:8000/users/1
you will see:
{"id":1,"name":"John"}
You can play around with the user ID to see different users or try a non-existent user to see what happens.
Examples here follow closely the documentation from "routing-controllers" as to validate parity of funcionality.
Grout assumes that the controller will return JSON. However depending on the return type the content type is assumed. If the controller method already returns a Response
then the response is just passed along. If the controller method path has an extension, it is used to determine the content type.
Return Type | Content Type |
---|---|
String |
text/html |
ArrayBuffer |
application/octet-stream |
Response |
(embedded in response) |
.<extension> |
Value of contentType("<extension>") (for example extension ".pdf") |
All Others ... | application/json |
Below are examples of png
and pgp
content types (with extension and raw). Full runnable examples in tests.
class UserController {
// Other routes ...
// GET /users/:id/avatar.png
get_$id_avatar$$png(id = -1) {
if (!users.find(u => u.id === id)) throw new Deno.errors.NotFound();
const png = "iVBORw0KG ... rkJggg==";
return atob(png);
}
// GET /users/pgp
get_pgp() {
// See https://www.ietf.org/rfc/rfc3156.txt
const pgp = `-----BEGIN PGP MESSAGE----- ... -----END PGP MESSAGE-----`;
return new Response(pgp, { headers: { "Content-Type": "application/pgp-encrypted" } });
}
}
You can return either promises or direct values. The controller will wait and send the right response value.
You can use framework's request
by adding a parameter with that name to the method (which will inject a Web API Request). If you want to handle the response by yourself, you need to return the created a Response object.
export class DocumentController {
// GET /document/:id/license
getLicense(request: Request) {
if (id === 42) return "Universal License";
else return "MIT License";
}
// GET /document/:id/polict
getPolicy($request: Request, $response: Response) {
// Redirects all document policies to wikipedia website's policy
return Response.redirect("https://meta.wikimedia.org/wiki/Privacy_policy");
}
}
The Request
and Response
types are directly accessible in Deno's global namespace.
Use method loadControllers
which returns a map of controllers.
🚧 Work in progress
Feature | Routing Controllers | Grout |
---|---|---|
Load All Controllers | ✅ createExpressServer |
✅ loadControllers |
Prefix All Controllers | ✅ createExpressServer |
|
Prefix controller with base route | ✅ @Controller |
✅ base argument |
Inject routing/query parameters | ✅ @Param |
✅ Named function parameter |
Typed Parameters | ✅ isRArray and type |
✅ Use defaults |
Inject request body | ✅ i@Body |
✅ Named body parameter |
Inject request body parameters | ✅ @BodyParam |
body.param |
Inject request header parameters | ✅ @HeaderParam |
headers.param |
Inject cookie parameters | ✅ @CookieParam |
cookies.param |
Inject session object | ✅ @SessionParam |
✅ Named session parameter |
Inject state object | ✅ @State |
❌ Niche, not supported |
Inject uploaded file | ✅ @UploadedFile |
✅ Named file(s) parameter |
Make parameter required | ✅ required |
✅ Parameter with default |
Convert parameters to objects | new on body |
|
Set custom ContentType | ✅ @ContentType |
Response |
Set Location/Redirect/Code | ✅ @Location |
Response |
Render templates | ✅ @Render |
❌ Specialized, left to server |
Throw HTTP errors | ✅ Via exceptions | ✅ Via exceptions |
Middlewares | ✅ @UseBefore/@UseAfter |
❌ Specialized, left to server |
Interceptors | ✅ Via @UseInterceptor |
|
Auto validating action params | ✅ Via validation |
❌ Specialized, left to validation library |
Authorization | ✅ Via @Authorized |
❌ Specialized, support of user /session |
- Implement interceptors
- Provide examples of session, user, headers parameters
To make sure that Github can Codecov can talk, you need to set the CODECOV_TOKEN
environment variable in the Github repository settings:
gh secret set CODECOV_TOKEN --body "$CODECOV_TOKEN"