OxygenFramework / Oxygen.jl

💨 A breath of fresh air for programming web apps in Julia
https://oxygenframework.github.io/Oxygen.jl/
MIT License
383 stars 25 forks source link

feature: ergonomic extractors #183

Closed nguiard closed 2 months ago

nguiard commented 2 months ago

Hi, I wonder if it would be possible (and if you'd be interested) to have ergonomic "extractors" in Oxygen where you could write something along the lines of:

struct MyForm
    task::String
    weight::Int8
end

function myendpoint(req, f::Form{MyForm})
    ...
end

And Oxygen would call myendpoint only if it was able to effectively build a MyForm value from the request, "extracting" it before giving it to you.

This is simply an ergonomics thing where you no longer have to write boilerplate code at the start of endpoints to check that the data is correct for the type you expect, but it is very nice to have. Clearly inspired by Rust's axum library, which with clever use of the type system and a bit of macro magic, enables you to just write things like this:

use axum::extract::Form;

struct MyForm {
    task: String,
    weight: u8,
}

async fn myendpoint(Form(f): Form<MyForm>) -> &'static str {
    ...
}

And it works, axum will only call that endpoint if the data fits, giving you a form of the type you requested. I'd love to see something like that in Oxygen, does it fit the philosophy?

ndortega commented 2 months ago

Hi @nguiard,

I think having some form of extractors aligns well with this packages goals and philosophy. When I first started this package I wanted to emulate the FastApi framework which is known for it's light syntax and great features. FastApi is able to extract query and path parameters in a super clean way without any special syntax.

In the example below:

@app.get("/items/{item_id}")
async def read_item(item_id, skip: int = 0, limit: int = 10):
    return {"item_id": item_id, "skip": skip, "limit": limit}

FastAPI is able to extract and parse the values with little ceremony, but it may not be completely obvious to an outside reader what's going on. On the other end of the specturm we have frameworks like axum which have an explicit extractor for each type of data you'd like extract from a request.

#[derive(Deserialize)]
struct Pagination {
    skip: Option<usize>,
    limit: Option<usize>,
}

async fn read_item(Path(item_id): Path<String>, Query(Pagination): Query<Pagination>) -> String {
    ...
}

This takes more setup but it's super obvious to the reader where the data is coming from and what shape it will have. I'd argue that the best way forward is to offer a hybrid of both approaches.

This progressive approach would allow people to choose how explicit & how much validation they want, without forcing one coding style on everyone. At the end of the day, both approaches would provide type saftey to the routes, but the extractors could be used to provide extra readability and validation checks.

Here's a rough Idea of what i'm proposing:


using Oxygen

# Required keywords (with types)
@get "/items/{item_id}" function(req, item_id::Int, skip::Int, limit::Int)
    ...
end

# Optional keywords with defaults (the type can be inferred)
@get "/items/{item_id}" function(req, item_id::Int, skip = 0, limit = 10)
    ...
end

# Optional keywords with defaults and type defs
@get "/items/{item_id}" function(req, item_id::Int, skip::Int = 0, limit::Int = 10)
    ...
end

# Setup struct with types and defaults
@kwdef struct Pagination
    skip::Int = 0
    limit::Int = 10
end

# Add a custom validate function that can be dispatched to when validating internally
validate(p::Pagination) = p.skip >= 0 && p.limit >= 0

# Full blown extractors with optional defaults
@get "/items/{item_id}" function(req, item_id::Int, pagination::Query{Pagination})
    ...
end
nguiard commented 2 months ago

Oh hi! I'm sorry, I missed the notification about your response and am just seeing it now. Glad to see that this was of interest to you and that Oxygen is evolving! :)