update_001.robin

Published on November 24, 2024

Why write this article?

This post is a follow-up to my last article and the first in a series of updates I intend to start posting for the things I work on. While you can indeed read the changelogs (which I try to keep informative as possible but still fail at in my opinion), it requires you to be actively following me or the project on GitHub, but there is a good chance you had a look at the project when I first talked about it, realised it didn’t have what you wanted, clicked off, and forgot about it; perhaps one of these updates will includes the things you want.

Middleware functions

Middleware support was added very early into the library but was not really talked about; here is the commit that added that feature. It does what it says on the tin, middleware functions are functions that are executed before the actual procedure function, you can chain as many as you need and they are guaranteed to be executed in that order.

Middleware functions have the following signature:

type Middleware func(*Context) error

Unlike normal procedures, you can only return errors (or nil if it should continue down the chain) and this is on purpose, middleware functions are designed to only be, well, middleware functions; they are not the final destination, they are not supposed to behave like normal procedures and leave you wondering where the Ok response was actually sent; that will ALWAYS be in the procedure function.

Middleware functions, as you might have noticed, also do not take the deserialized input but rather just the raw context for a very good reason; re-usability. Forcing middleware functions to conform to a specific input type makes it difficult to reuse them across procedures or even across projects (i.e. as separate packages if necessary).

This comes with the downside that you’d have to deserialize the body twice (once explicitly, and the other implicitly by Robin), I have refrained from adding an option to tell robin “Don’t worry, I have deserialized the input already, here it is, skip the process and just use this” because, while I do want to provide some level of control, I won’t pretend this library is not opinionated and it has to be. It is designed to keep things less confusing, keep the gnarly details out of your way until you need them and prevent silly human errors, you WILL forget to deserialize at some point, tell Robin you have and cause a panic.

It is easy to feel this is a library for idiots who need to be protected from themselves; like myself, but that is not the case. I prefer to let the computer do work it is good at and not get in my own way, we don’t need to bang rocks together all the time to show just how intelligent and infallible we are.

To be fair, that is a tough balance to achieve - what should and should not be hidden away? Robin is an experiment for now, and some things may be removed in the future.

In the future, I may add this option with a variety of checks - that is already possible - but I don’t see a need for it now.

Example

Definition

package foo import ( "encoding/base64" "log/slog" "net/http" "todo/repository" apperrors "todo/pkg/errors" "go.trulyao.dev/robin" ) func RequireAuth(c *robin.Context) error { authCookie, found := c.Cookie("auth") if !found { return apperrors.New(http.StatusUnauthorized, "Unauthorized") } username, err := base64.StdEncoding.DecodeString(authCookie.Value) if err != nil { slog.Error("Failed to decode auth cookie", slog.String("cookie", authCookie.Value)) return apperrors.New(http.StatusUnauthorized, "Unauthorized") } user, err := repository.UserRepo().FindByUsername(string(username)) if err != nil { slog.Error("Failed to find user", slog.String("username", string(username))) return apperrors.New(http.StatusUnauthorized, "Unauthorized") } c.Set("user", user) return nil } func Log(c *robin.Context) error { // do something here return nil }

As you might have noticed, Robin has an undocumented in-memory State container where you can store data and access it later down the chain for convenience.

Usage

func main() { // There are other ways to set the middleware functions like the `QueryWithMiddleware` constructor ... instance, err := r.Add(robin.Query("list-todos", ListTodos).WithMiddleware(Log, RequireAuth)) ... // Execution order: `Log` -> `RequireAuth` -[if nil]-> `ListTodos` }

This was extracted from an earlier version of the Todo list demo app.

Global middlware functions

You might have noticed a problem already, passing your middleware functions one-by-one to tens or may even hundreds of procedures can get exhausting really fast and would somewhat defeat the effeciency/iteration-speed reasons for going with this library in the first place.

For this reason, version 0.4 added support for (named) global middleware which still provide nearly the same guarantees as before and can be added to the instance itself with one key change: they are now opt-out instead of opt-in. You can find the rationale for the current design of global middleware functions here.

In the future, I may introduce some sort of procedure grouping functionality as robin.Group to make it easier to apply middleware functions to only a certain group of procedures instead of having to mass-opt-out (and in turn recreating the original “repitition” problem) - tracked as #40

Example

Usage

func main() { ... r.Use("log", Log) r.Use("require-auth", RequireAuth) instance, err := r. // **None** of the global middleware functions will be executed for this procedure Add(robin.Query("whoami", WhoAmI).ExcludeMiddleware("*")). // **All** of the global middleware functions will be executed for this procedure Add(robin.Query("list-todos", ListTodos)). // The `require-auth` middleware function will not be executed for the following procedures Add(robin.Mutation("sign-in", h.SignIn).ExcludeMiddleware("require-auth")). Add(robin.Mutation("sign-up", h.SignUp).ExcludeMiddleware("require-auth")). ... }

There is a known issue where arbitrary names can be passed to ExcludeMiddleware are not validated, this is tracked as #35

REST-ful endpoints

While Robin generates a type-safe TypeScript client for you, there are cases where you probably want to expose a REST-ful API for other developers to build on, or you simply are not working in TypeScript and cannot use the client. It is fairly easy to reverse-engineer the client to get the URLs but you will soon find out they are not REST-ful and are (in my opinion) ugly and hard to remember, which makes sense, they were not designed for you to look at or use outside the generated client(s). This PR laid the foundation for future work like generating Open API/Swagger specs and web documentation amongst other things, which means you can expose REST-ful endpoints today!

I will admit it is still clearly under-developed (because it is), and is quite limited when it comes to customisation, but there is now a new method WithAlias to set a different endpoint for the REST-ful layer.

Example

Definition

You don’t need to rewrite your existing code to make use of this feature, you just need to pass in a new option to the Serve options (if you are using that) to enable REST endpoints, or attach the handlers manually using the new BuildRestEndpoints and BuildProcedureHttpHandler methods on the instance.

See documentation for BuildProcedureHttpHandler and BuildRestEndpoints

func main() { r, err := robin.New(robin.Options{/* ... */}) if err != nil { log.Fatalf("Failed to create a new Robin instance: %s", err) } i, err := r. Add(robin.Query("ping", ping)). Add(robin.Query("list-todos", listTodos)). // You can name your procedures like this or use an alias as shown below Add(robin.Mutation("create.todo", createTodo).WithAlias("/todo/new")). // You can also add aliases to mutations! (not like this though, bad path) Build() if err != nil { log.Fatalf("Failed to build Robin instance: %s", err) } if err := i.Serve( robin.ServeOptions{ /* ... */ RestApiOptions: &robin.RestApiOptions{Enable: true}, }); err != nil { log.Fatalf("Failed to serve Robin instance: %s", err) return } } func ping(ctx *robin.Context, _ robin.Void) (string, error) { /*...*/ } func listTodos(ctx *robin.Context, _ robin.Void) ([]Todo, error) { /*...*/ } func createTodo(ctx *robin.Context, todo Todo) (Todo, error) { /*...*/ }

By default, the library will strip out certain prefixes based on the procedure type to make the endpoints cleaner and drop the leading verbs which only make sense in the generated client but not for REST-ful endpoints. For example, list-todos will become a GET /<path>/todos request instead of GET /<path>/list-todos, you can override this by using the WithAlias method.

Usage

You can proceed to call the REST endpoints using any tool you prefer in any language, here is an HTTP file matching the Robin instance defined above (along with matching CURL commands):

@url=http://localhost:8060/api GET {{url}}/ping Content-Type: application/json Accept: application/json ### # List todos GET {{url}}/todos Content-Type: application/json Accept: application/json ### # Create todo POST {{url}}/todo/new Content-Type: application/json Accept: application/json { "d": { "title": "Test todo", "completed": true } }
# ping curl -X GET 'http://localhost:8060/api/ping'
# list-todos curl -X GET 'http://localhost:8060/api/todos'
# create.todo curl -X POST 'http://localhost:8060/api/todo' \ --data-raw $'{ "d": { "title": "Todo\'s title", "completed": true } }'

The default REST endpoint path is /api, you can also customize this in the RestApiOptions

Minor changes

Extra options in client

Tracked as #31

Users can now supply extra fetch options like credentials to the built-in HTTP client used by the generated client, this previously required providing a custom implementation.

Example

import Client from "./bindings"; const client = Client.new({ endpoint: import.meta.env.DEV ? "http://localhost:8081/_robin" : "/_robin", fetchOpts: { credentials: "include", }, }); export default client;

Exposed pre-flight and CORS handlers

Tracked as #30

The default (customisable) CORS handlers used by the built-in Serve method on the robin instance are now available to users who do not want to use the built-in Serve method.

Major Bug fixes

  • Prevent overriding procedures with the same name but different types(#33)

Conclusion

You can view the live version of the demo todo application here with the source code available at github.com/aosasona/robin-todo.

I have also started work on the documentation site, which is not going to be available for a long while as I have other commitments at the moment but you can follow the progress (if any) at https://robin.trulyao.dev.

That’s it for now, you can track planned & on-going work and known issues here, you can also open an issue to tell me what you currently dislike or would like to see in the future!

Edit this page on Github

Disclaimer: This article represents my own opinions and experiences at the time of writing this article. These opinions may change over time and my experiences could be different from yours, if you find anything that is objectively incorrect or that you need to discuss further, please contact me via any of the links in the header section of this website's homepage.