[ANN] Oxygen.jl 1.5.x Recap (v1.5.0 - v1.5.8)

Hello everyone,

It’s been a couple of months since my last update on Oxygen.jl, and I wanted to take the chance to go over all the cool updates to the framework.

This might be a somewhat longer post so I’ll list the highlighted features below:

v1.5.0: @oxidise

In this version the @oxidise macro was introduced which made it possible to create multiple independent instances of oxygen within the same julia program.

In the example below we show how this macro can be used to create a package that builds on top of Oxygen with its own state. It works by creating a new local state and binding it to locally generated stateful methods that make up the oxygen api.

You might wonder if there’s a conflict when it comes to method resolution due to the “using” import below. Because of Julia’s scoping rules, the local versions of the stateful methods are called instead of the ones defined within Oxygen (those are mapped to the default global state).

module MyApp
using Oxygen; @oxidise

export start, stop

@get “/” function home() 
    return text(“hello world”)
end

start() = serve()
stop() = terminate()

end

To get this macro working required a near complete refactor of the package to make the internals more functional. This was a huge undertaking and was spearheaded by @JanisErdmanis, who not only thought of the idea, but also implemented most of the final solution. By the end of the refactor, 59 separate files were changed with 3,062 additions and 2,067 deletions.

Aside from making the project much more modular, this was a huge help when it came to Oxygens unit testing strategy. With these new changes, I wouldn’t have to worry about other modules routes accidentally getting registered outside of the current module. This has been a huge speed up in unit testing and now I use it everywhere.

v1.5.1 - v1.5.2: Plotting Extensions

In this version I added several package extensions for plotting, specifically for Makie.jl, Cairo.jl and Bonito.jl. These extensions make it easy to return plots directly from request handlers. All operations are performed in-memory using an IOBuffer and return a HTTP.Response

Supported Packages and their helper utils:

  • CairoMakie.jl: png, svg, pdf, html
  • WGLMakie.jl: html
  • Bonito.jl: html

Makie.jl

using CairoMakie: heatmap
using Oxygen

@get "/cairo" function()
    fig, ax, pl = heatmap(rand(50, 50))
    png(fig)
end

serve()

WGLMakie.jl

using Bonito
using WGLMakie: heatmap
using Oxygen
using Oxygen: html # Bonito also exports html

@get "/wgl" function()
    fig = heatmap(rand(50, 50))
    html(fig)
end

serve()

Bonito.jl

using Bonito
using WGLMakie: heatmap
using Oxygen
using Oxygen: html # Bonito also exports html

@get "/bonito" function()
    app = App() do
        return DOM.div(
            DOM.h1("Random 50x50 Heatmap"), 
            DOM.div(heatmap(rand(50, 50)))
        )
    end
    return html(app)
end

serve()

v1.5.3: Protobuf Extension

In this version support was added for protocol buffers from ProtoBuf.jl through a package extension with the protobuf function. This behaves very closely to our other body parsers and render functions.

This function has overloads for the following scenarios:

  • Decoding a protobuf message from the body of an HTTP request.
  • Encoding a protobuf message into the body of an HTTP request.
  • Encoding a protobuf message into the body of an HTTP response.
using HTTP
using ProtoBuf
using Oxygen

# The generated classes need to be created ahead of time (check the protobufs)
include("people_pb.jl");
using .people_pb: People, Person

# Decode a Protocol Buffer Message 
@post "/count" function(req::HTTP.Request)
    # decode the request body into a People object
    message = protobuf(req, People)
    # count the number of Person objects
    return length(message.people)
end

# Encode & Return Protocol Buffer message
@get "/get" function()
    message = People([
        Person("John Doe", 20),
        Person("Alice", 30),
        Person("Bob", 35)
    ])
    # seralize the object inside the body of a HTTP.Response
    return protobuf(message)
end

The following is an example of a schema that was used to create the necessary Julia bindings. These bindings allow for the encoding and decoding of messages in the above example.

syntax = "proto3";
message Person {
    string name = 1;
    sint32 age = 2;
}
message People {
    repeated Person people = 1;
}

v1.5.4: Steam & Websocket handlers

Introduced @stream & stream() and @websocket & websocket() handlers

Up until this point, Oxygen only supported registering request handlers. To use websockets or streaming you had to either open up a separate http server for these specific features, or use middleware in a clever way to get these features working.

In HTTP.jl you can register stream handlers to the router, but as far as I know there’s no way to register a websocket handler to the router. Based on the examples, you need to spin up a separate server just to handle websocket connections.

After doing some quick prototyping (and lots of hardcoding), I was able to get these connected to the router, but wasn’t able to get any path params support working. Before officially adding them to Oxygen, they needed to function exactly like request handles and support other features like router’s and path parameters.

This took a decent amount of experimentation, but resulted in a solution that’s been working well so far. During route registration, we dispatch on the type of the first argument and generate the code to call the registered handler. As with most things in Oxygen, we try to do as much work as possible during the startup to minimize the amount of work needed to handle requests at runtime.

With these changes in place, oxygen can now handle both streaming and websockets connections on the same server in a way that feels very straightforward.

Handler Rules:

  • Handlers can be imported from other modules and distributed across multiple files for better organization and modularity
  • All handlers have equivalent macro & function implementations and support do…end block syntax
  • The type of first argument is used to identify what kind of handler is being registered
  • This package assumes it’s a Request handler by default when no type information is provided
using HTTP
using Oxygen

# Request Handler
@get "/" function(req::HTTP.Request)
    ...
end

# Stream Handler
@stream "/stream" function(stream::HTTP.Stream)
    ...
end

# Websocket Handler
@websocket "/ws" function(ws::HTTP.WebSocket)
    ...
end

request keyword in Handlers

If you need to access the connection request inside a streaming or websocket handler, you can add a request keyword to your function handler. In most cases, you won’t need to access the initial request, but it’s there if you need it. This was done to avoid confusion with any path parameter definitions

# Websocket Handler
@websocket "/ws" function(ws::HTTP.WebSocket; request)
    ...
end

Implicit vs Explicit stream & websocket handlers

One neat side effect of this implementation is that you can register streaming routes with other request macro’s & functions. Since streaming isn’t required to only work with GET requests, there’s nothing stopping you from setting these up with @put or @post macros.

The only caveat is that you need to make sure these are explicitly typed to get them working. This same behavior is available for websockets too, but it will only work for GET request types because the protocol requires it.

# implicit stream
@stream "/ws" function(stream)
    ...
end

# explicit stream
@get "/stream/explicit" function(stream::HTTP.Stream)
    ...
end

# implicit ws
@websocket "/ws" function(ws)
    ...
end

# explicit ws
@get "/ws/explicit" function(ws::HTTP.WebSocket)
    ...
end

v1.5.5 - v1.5.8: Request Extractors & Query Params

In the most recent update we introduced support for Request Extractors and query params to our handler functions. They provide an easy & safe way to describe how to serialize data from incoming requests with very little code.

This feature was directly inspired from the axum.rs web framework, which is famous for its ergonomics and extractors.

Below is a simple example demonstrating how to use the new Json extractor to automatically serialize data from a request and pass it to a handler.

struct Person
    name::String
    age::Int
end

@post "/person" function(req, newperson::Json{Person})
    return newperson.payload # access the serialized person
end

While this is helpful for json payloads, this doesn’t really save us a huge amount of time or code. This type of conversion could’ve been done in a single line with our json() function. where this really gets interesting is when we introduce other extractors to do more complicated extractions & serialization.

For example, let’s take a look at a basic form submission example:

struct Person
    name::String
    age::Int
end

# Setup a basic form
@get "/" function()
    html("""
    <form action="/form" method="post">
        <input type="text" id="name" name="name">
        <input type="number" id="age" name="age">
        <input type="submit" value="Submit">
    </form>
    """)
end

# Parse the form data and return it
@post "/form" function(req)
    data = formdata(req)
    return Person(data[“name”], parse(Int, data[“age”]))
end

In this example, we have a /form endpoint which parses the form data from a request body. But this example doesn’t do any type conversions or casting, all that has to be done manually. This is fine for smaller structs, but as inputs get more complicated, types get nested, and refactors happen - it’s easy to mess up the serialization.

That’s where the extractors really shine, if you need to refactor your struct to accept a new property, your struct is the only thing you have to update! Oxygen uses the struct as the single source of truth and uses it to build the instance. This makes it easier to refactor your endpoints knowing that your handler is type safe.

@post "/form" function(req, userform::Form{Person})
    person = userform.paylaod
    return person
end

Supported Extractors:

  • Path- extracts from path parameters
  • Query - extracts from query parameters,
  • Header - extracts from request headers
  • Form - extracts form data from the request body
  • Body - serializes the entire request body to a given type (String, Float64, etc…)
  • ProtoBuffer - extracts the ProtoBuf message from the request body (available through a package extension)
  • Json - extracts json from the request body
  • JsonFragment - extracts a “fragment” of the json body using the parameter name to identify and extract the corresponding top-level key

Using Extractors & Parameters

In this example we show that the Path extractor can be used alongside regular path parameters. This Also works with regular query parameters and the Query extractor.

struct Add
    b::Int
    c::Int
end

@get "/add/{a}/{b}/{c}" function(req, a::Int, pathparams::Path{Add})
    add = pathparams.payload # access the serialized payload
    return a + add.b + add.c
end

Default Values

Default values can be setup with structs using the @kwdef macro.

@kwdef struct Pet
    name::String
    age::Int = 10
end

@post "/pet" function(req, params::Json{Pet})
    return params.payload # access the serialized payload
end

Validation

On top of serializing incoming data, you can also define your own validation rules by using the validate function. In the example below we show how to use both global and local validators in your code.

  • Validators are completely optional
  • During the validation phase, oxygen will call the global validator before running a local validator.
import Oxygen: validate

struct Person
    name::String
    age::Int
end

# Define a global validator 
validate(p::Person) = p.age >= 0

# Only the global validator is ran here
@post "/person" function(req, newperson::Json{Person})
    return newperson.payload
end

# In this case, both global and local validators are ran (this also makes sure the person is age 21+)
# You can also use this sytnax instead: Json(Person, p -> p.age >= 21)
@post "/adult" function(req, newperson = Json{Person}(p -> p.age >= 21))
    return newperson.payload
end

Putting It all Together

With all these features, It’s now easier than ever to build more succinct and safer web services in Oxygen.

Below is a psuedo example where we incorporate most of the features discussed for demoing purposes. It represents a package created to help submit orders for products and graph the results. It uses plotting, streaming, validators, extractors, path & query parameters.

module StoreApi

using Base.Threads: lock, ReentrantLock
using Dates
using JSON3
using Bonito
using WGLMakie: barplot
using Oxygen; @oxidise
using Oxygen: html # Bonito also exports html
import Oxygen: validate # so we can overload it

export start, stop

@kwdef struct Order
    uuid::UUID
    product_id::UUID
    timestamp::DateTime
    quantity::Int = 1
end

# ensure we don't accept empty orders
validate(order::Order) = order.quantity > 0

# Define a channel to store all the orders
const orders = Channel{Order}(Inf)

# Track all active connections
const connections = Ref{Vector{HTTP.Stream}}([])
const conn_lock = ReentrantLock()
const PUBLISH = Ref{Bool}(true)

# Setup path params, query params and extractors to parse out data
@post "/order/create/{user_id}" function(req, user_id::UUID, order::Json{Order}, referral::String)
    # call a pseudo function to place the order
    submit(order.payload, referral)
    put!(orders, order.payload)
    return text("$user_id placed an order!")
end

# Plot the metrics
@get "/plots/sales" function()
    # call a pseudo function to calculate the current sales metrics
    metrics = get_sales_data()
    app = App() do
        return DOM.div(
            DOM.h1("Sales Metrics"), 
            DOM.div(barplot(metrics)) 
        )
    end
    return html(app)
end

# Subscriber
@stream "/realtime/orders" function(stream::HTTP.Stream)
    lock(conn_lock) do 
        if isopen(stream)
            push!(connections[], stream)
        end
    end
    while isopen(stream)
        sleep(1)  # Wait for a short period to reduce CPU usage
    end
end

# Publisher (will send the newly created order to all subscribers & remove any closed connections)
@async begin
    # Use server sent events (SSE) to send out order details in real-time
    while PUBLISH[]
        # Wait for new orders to get created
        if isready(orders)
            order = take!(orders)
            lock(conn_lock) do 
                connections[] = filter(connections[]) do conn
                    if isopen(conn)
                        write(conn, format_sse_message(JSON3.write(order)))
                        return true
                    end
                    return false
                end
            end
        end
        sleep(1) # Wait for a short period to reduce CPU usage
    end
end

# Helper functions to start and stop the application
function start()
    PUBLISH[] = true
    serve()
end

function stop() 
    PUBLISH[] = false
    connections[] = []
    empty!(orders)
    terminate()
end

end

Conclusion

Lastly, I just want to thank everyone who’s contributed to this project, whether that be through a pull request, written articles, submitting bug reports or brainstorming up new ideas for the framework.

I don’t think I’m exaggerating when I say most of great ideas for this project have come from the community and that this project wouldn’t be where it is without your help.

On a related note, It’s almost Oxygen’s 2nd birthday and I’ve recently had fun taking a look at the initial release notes and comparing it to the feature set it has today. I really appreciate the early adopters who bravely adopted the project when it was still in its early stages when it was led by a developer with virtually no open-source experience.

The project has grown so much in the past 2 years, and I can’t wait to see what it looks like at the 4-year mark. Thank you all for your support and kind words, I look forward to seeing you all in the next update!

27 Likes