Expr Lang: Go centric expression language for dynamic configurations
We're the builders of Cosmo, a complete open source platform to manage Federated GraphQL APIs. One part of the platform is Cosmo Router, the GraphQL API Gateway that routes requests from clients to Subgraphs, implementing the GraphQL Federation contract.
The Router is a Go application that can be built from source or run as a Docker container. Most of our users prefer to use our published Docker images instead of modifying the source code for customizations.
We're hiring!
We're looking for Golang (Go) Developers, DevOps Engineers and Solution Architects who want to help us shape the future of Microservices, distributed systems, and APIs.
By working at WunderGraph, you'll have the opportunity to build the next generation of API and Microservices infrastructure. Our customer base ranges from small startups to well-known enterprises, allowing you to not just have an impact at scale, but also to build a network of industry professionals.
To provide a flexible configuration system, we decided to use an expression language called Expr Lang .
The challenge: How to provide a flexible configuration system
One of the issues we keep running into is how we can allow our users to customize the behavior of the Router. As mentioned above, our users are not very keen on modifying the source code, so we wanted to provide a way to customize the Router without having to rebuild it.
Our Router is configured using a YAML file, which is loaded at startup and used to configure the Router's behavior.
The problem with this approach is that YAML is not very flexible. We found ourselves more and more often in a situation where we had to embed a "half-baked" expression language into the YAML file to allow for more complex configurations.
I'll give you two examples of problems that are hard to solve with plain YAML:
- Blocking Mutations during Migrations
One of our customers needed this feature to implement their migration strategy. While shifting traffic from one legacy system to our solution, they wanted to send shadow traffic to Cosmo Router to validate the responses.
To achieve this without causing any data corruption, it was critical to block all mutations during the migration. If you're not familiar with GraphQL, mutations are the operations that modify data on the server.
The challenge with this requirement is that they wanted to use the same Router deployment and configuration for both shadow and production traffic. Consequently, we couldn't just disable mutations in the configuration file because that would disable mutations for production traffic as well.
- Rate Limiting individual clients or users
Another case where we needed a more flexible configuration system was rate limiting. To implement rate limiting, you need two essential components: A key to identify a unique client or user of the system, and a rate limit that defines how many requests a client can make in a given time frame.
The challenge with this use case is that there might be different rules we'd like to apply to different client groups. A client might be an authenticated user, or an anonymous agent without any credentials. As such, we needed a way to derive a key from the incoming request that works in both cases.
The solution: Expr Lang
To solve these problems we decided to use Expr Lang .
Expr is a Go-centric expression language designed to deliver dynamic configurations with unparalleled accuracy, safety, and speed. Expr combines simple syntax with powerful features for ease of use.
Here are some examples from the Expr Lang documentation:
We decided to use Expr Lang for multiple reasons:
- Expr Lang is Safe & Side Effect Free: Expressions are evaluated in a sandboxed environment, ensuring that they can't cause any side effects.
- Expr Lang is Always Terminating: Expr is designed to prevent infinite loops, ensuring that every program will conclude in a reasonable amount of time.
- Expr Lang is Fast: Compilation and execution is split into two phases, and benchmarks have shown that Expr is very fast with minimal overhead.
Blocking Mutations during Migrations
The first problem we had to solve was blocking mutations during migrations.
To achieve this with Expr Lang, we've created a Context object which provides useful information about the incoming request. The Context object is passed to the expression evaluator, which allows our users to write expressions that can access the request and make decisions based on it.
Here's how you can access a request header in an expression:
Expr Lang also provides an easy way to evaluate boolean expressions. So, to block mutations during migrations, we can use the following expression:
If the X-Shadow-Traffic
header is present in the request, the operation will be blocked.
Rate Limiting individual clients or users
The second problem we had to solve was rate limiting individual clients or users. For this use case, we'd like to derive a key from an authenticated user or fall back to the client's IP address.
To make this possible, we're parsing the JWT from the incoming request and provide it in the Context object as such:
Another feature of Expr Lang we're leveraging is nil coalescing. This allows us to provide a fallback value if no sub claim is present, meaning that the user is not authenticated:
This expression will return the sub claim from the JWT if present, otherwise it will return the IP address from the X-Forwarded-For
header.
How is Expr Lang better than a custom solution with YAML implemented in Go?
Being a good developer means that you're considering all options before making a decision. So, why did we choose Expr Lang over a custom solution with YAML implemented in Go?
Let's try to implement the second example with a custom solution using YAML. Here's how the configuration might look like:
To implement this configuration in pure YAML, we need to find a syntax that allows us to access both the claim and the header, appending them to the key if they're present, and designing a way to prefer the claim over the header.
For comparison, here's the same example implemented with Expr Lang:
There are two main problems with the YAML solution:
- While the expression is concise and easy to understand, the YAML configuration is verbose and it's not immediately clear what it does.
- We cannot re-use the YAML approach for other use cases, and we'd have to implement a new solution for each new requirement.
With the Expr Lang solution, we can define a central place that takes a request and turns it into a Context object which can be passed to the expression evaluator. This Context object can be extended easily, e.g. with new fields or methods, to support new requirements, and it can be used for other parts of the system as well. For example, we could use the same approach to propagate headers from the incoming request to origin requests, we can override a request URL in the transport layer, or we can implement custom authorization logic.
Alternatives to Expr Lang
At this point, it should be clear that an expression language in general is a good solution to the problem of providing a flexible configuration system. So before we wrap up, let's take a look at some alternatives to Expr Lang.
Our Router is written in Go, so we're only looking at solutions that are compatible with Go.
Go Templates as an alternative to Expr Lang
The first alternative that comes to mind is Go Templates. Go Templates comes with the standard library, it's very powerful and well documented.
Let's take a look at how we could implement the second example with Go Templates:
What's missing in Go Templates is the ability to derive the return type of an expression. Go Templates render the output as a string. Expr Lang, on the other hand, has a compile time step that allows you to verify the correctness of an expression before it's evaluated at runtime. For example, if an expression returns either a boolean or a string, Expr Lang will return an error at compile time. This allows us to define a strict contract between the expression evaluator and the rest of the system.
CEL / Common Expression Language as an alternative to Expr Lang
Another alternative we've considered is CEL .
The Common Expression Language (CEL) is a non-Turing complete language designed for simplicity, speed, safety, and portability. CEL's C-like syntax looks nearly identical to equivalent expressions in C++, Go, Java, and TypeScript.
Here's how the second example might look like in CEL:
CEL doesn't just look very similar to Expr Lang, it also has a lot of the same features. We've decided to go with Expr Lang as we've liked the ecosystem and documentation better.
otto/goja JavaScript engine as an alternative to Expr Lang
Another approach we've considered is to use a JavaScript engine like otto or goja . Both solutions allow you to evaluate JavaScript code in Go using an embedded JavaScript engine.
The upside of this approach is that JavaScript is a very powerful language with a much bigger ecosystem than Expr Lang. In addition, both otto and goja don't rely on V8, so they don't need CGO or external dependencies.
On the other hand, JavaScript is a Turing complete language, which means that it's possible to write expressions that don't terminate. Both solutions also come with significant overhead compared to a lightweight expression language when it comes to performance. An embedded JavaScript engines requires a lot more CPU and memory, and it's much slower compared to Expr Lang.
Overall, we've decided to go against using an embedded JavaScript engine not just because of the performance overhead, but because we're concerned that users might write expressions that are too complex, buggy, or accidentally cause negative side effects.
Conclusion
Adding an expression language to the Router allows us to give our customers a lot of flexibility in how they use the Router, all without having to implement a custom solution for each new requirement.
We've decided to use Expr Lang because it's safe, side effect free, always terminating, and fast. Other alternatives like Go Templates, CEL, or an embedded JavaScript engine might work as well, but we've found that Expr Lang is the best fit for our use case.
We're hiring!
We're looking for Golang (Go) Developers, DevOps Engineers and Solution Architects who want to help us shape the future of Microservices, distributed systems, and APIs.
By working at WunderGraph, you'll have the opportunity to build the next generation of API and Microservices infrastructure. Our customer base ranges from small startups to well-known enterprises, allowing you to not just have an impact at scale, but also to build a network of industry professionals.
If you like the problems we're solving and want to learn more about Cosmo Router, check out our GitHub repository and take a look at our open positions if you're interested in joining our team.