Skip to content
/ grout Public

Simple replacement for routing controllers with no annotations and no dependencies

Notifications You must be signed in to change notification settings

acrodrig/grout

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🪆 Grout

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).

Motivation

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).

Installation

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".

Example of usage

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.

More examples

Examples here follow closely the documentation from "routing-controllers" as to validate parity of funcionality.

Working with json/html/pdf/etc

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" } });
  }
}

Returning promises

You can return either promises or direct values. The controller will wait and send the right response value.

Using Request and Response objects

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.

Load all controllers from the given directory

Use method loadControllers which returns a map of controllers.

Comparison with Routing Controllers

🚧 Work in progress

Feature Routing Controllers Grout
Load All Controllers createExpressServer loadControllers
Prefix All Controllers createExpressServer ⚠️ Left to server (or oak)
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 ⚠️ Not directly, use body.param
Inject request header parameters @HeaderParam ⚠️ Not directly, use headers.param
Inject cookie parameters @CookieParam ⚠️ Not directly, use 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 ⚠️ Via "class-transformers" ⚠️ Not directly, use new on body
Set custom ContentType @ContentType ⚠️ Not directly, via extensions and Response
Set Location/Redirect/Code @Location ⚠️ Not directly, via 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 ⚠️ Not yet, in the works
Auto validating action params ✅ Via validation ❌ Specialized, left to validation library
Authorization ✅ Via @Authorized ❌ Specialized, support of user/session

Todo

  • Implement interceptors
  • Provide examples of session, user, headers parameters

Miscellaneous

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"

About

Simple replacement for routing controllers with no annotations and no dependencies

Resources

Stars

Watchers

Forks

Packages

No packages published