Skip to content

Commit

Permalink
feature/middleware-refactor (#202)
Browse files Browse the repository at this point in the history
1.) refactored middleware building logic
2.) added caching to middleware logic
3.) moved middleware ordering tests into its own file
  • Loading branch information
ndortega authored Jun 6, 2024
1 parent b460bfe commit f09e6da
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 225 deletions.
5 changes: 2 additions & 3 deletions demo/ergonomicsdemo.jl
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
module ErgonomicsDemo
include("../src/Oxygen.jl")
using .Oxygen
import .Oxygen: validate, Param, hasdefault, splitdef, struct_builder, Nullable, LazyRequest, Extractor
using Oxygen
import Oxygen: validate, Param, hasdefault, splitdef, struct_builder, Nullable, LazyRequest, Extractor

using Base: @kwdef
using StructTypes
Expand Down
209 changes: 21 additions & 188 deletions src/autodoc.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module AutoDoc
module AutoDoc
using HTTP
using Dates
using DataStructures
Expand All @@ -10,13 +10,7 @@ using ..Constants
using ..AppContext: Context, Documenation
using ..Types: TaggedRoute, TaskDefinition, CronDefinition, Nullable

export registerschema,
swaggerhtml, redochtml, getschemapath, configdocs, mergeschema, setschema,
getrepeatasks, hasmiddleware, compose, resetstatevariables

const SWAGGER_VERSION = "[email protected]"
const REDOC_VERSION = "[email protected]"

export registerschema, swaggerhtml, redochtml, mergeschema

"""
mergeschema(route::String, customschema::Dict)
Expand All @@ -35,190 +29,27 @@ Merge the top-level autogenerated schema with a custom schema
"""
function mergeschema(schema::Dict, customschema::Dict)
updated_schema = recursive_merge(schema, customschema)
merge!(schema, updated_schema)
end


"""
returns true if we have any special middleware (router or route specific)
"""
function hasmiddleware(custommiddleware::Dict{String, Tuple})::Bool
return !isempty(custommiddleware)
end


"""
This function dynamically determines which middleware functions to apply to a request at runtime.
If router or route specific middleware is defined, then it's used instead of the globally defined
middleware.
"""
function compose(router, appmiddleware, custommiddleware)
return function(handler)
return function(req::HTTP.Request)
innerhandler, path, params = HTTP.Handlers.gethandler(router, req)
# Check if the current request matches one of our predefined routes
if innerhandler !== nothing

# always initialize with the next handler function
layers::Vector{Function} = [ handler ]

# lookup the middleware for this path
routermiddleware, routemiddleware = get(custommiddleware, "$(req.method)|$path", (nothing, nothing))

# calculate the checks ahead of time
hasrouter = !isnothing(routermiddleware)
hasroute = !isnothing(routemiddleware)

# case 1: no middleware is defined at any level -> use global middleware
if !hasrouter && !hasroute
append!(layers, reverse(appmiddleware))

# case 2: if route level is empty -> don't add any middleware
elseif hasroute && isempty(routemiddleware)
return req |> reduce(|>, layers)

# case 3: if router level is empty -> only register route level middleware
elseif hasrouter && isempty(routermiddleware)
hasroute && append!(layers, reverse(routemiddleware))

# case 4: router & route level is defined -> combine global, router, and route middleware
elseif hasrouter && hasroute
append!(layers, reverse([appmiddleware..., routermiddleware..., routemiddleware...]))

# case 5: only router level is defined -> combine global and router middleware
elseif hasrouter && !hasroute
append!(layers, reverse([appmiddleware..., routermiddleware...]))

# case 6: only route level is defined -> combine global + route level middleware
elseif !hasrouter && hasroute
append!(layers, reverse([appmiddleware..., routemiddleware...]))
end

# combine all the middleware functions together
return req |> reduce(|>, layers)
end
return handler(req)
end
end
end

"""
This functions assists registering routes with a specific prefix.
You can optionally assign tags either at the prefix and/or route level which
are used to group and organize the autogenerated documentation
"""
#function router(taggedroutes::Dict{String, TaggedRoute}, custommiddleware::Dict{String, Tuple}, repeattasks::Vector, prefix::String = "";
function router(ctx::Context, prefix::String = "";
tags::Vector{String} = Vector{String}(),
middleware::Nullable{Vector} = nothing,
interval::Nullable{Real} = nothing,
cron::Nullable{String} = nothing)

return createrouter(ctx, prefix, tags, middleware, interval, cron)
end

function createrouter(ctx::Context, prefix::String,
routertags::Vector{String},
routermiddleware::Nullable{Vector},
routerinterval::Nullable{Real},
routercron::Nullable{String} = nothing)

# appends a "/" character to the given string if doesn't have one.
function fixpath(path::String)
path = String(strip(path))
if !isnothing(path) && !isempty(path) && path !== "/"
return startswith(path, "/") ? path : "/$path"
end
return ""
end

# This function takes input from the user next to the request handler
return function(path = nothing;
tags::Vector{String} = Vector{String}(),
middleware::Nullable{Vector} = nothing,
interval::Nullable{Real} = routerinterval,
cron::Nullable{String} = routercron)

# this is called inside the @register macro (only it knows the exact httpmethod associated with each path)
return function(httpmethod::String)

"""
This scenario can happen when the user passes a router object directly like so:
@get router("/math/power/{a}/{b}") function (req::HTTP.Request, a::Float64, b::Float64)
return a ^ b
end
Under normal circumstances, the function returned by the router call is used when registering routes.
However, in this specific case, the call to router returns a higher-order function (HOF) that's nested one
layer deeper than expected.
Due to the way we call these functions to derive the path for the currently registered route,
the path argument can sometimes be mistakenly set to the HTTP method (e.g., "GET", "POST").
This can lead to the path getting concatenated with the HTTP method string.
To account for this specific use case, we've added a check in the inner function to verify whether
path matches the current passed in httpmethod. If it does, we assume that path has been incorrectly
set to the HTTP method, and we update path to use the router prefix instead.
"""
if path === httpmethod
path = prefix
else
# combine the current routers prefix with this specfic path
path = !isnothing(path) ? "$(fixpath(prefix))$(fixpath(path))" : fixpath(prefix)
end

if !(isnothing(routermiddleware) && isnothing(middleware))
# add both router & route-sepecific middleware
ctx.service.custommiddleware["$httpmethod|$path"] = (routermiddleware, middleware)
end

# register interval for this route
if !isnothing(interval) && interval >= 0.0
task = TaskDefinition(path, httpmethod, interval)
push!(ctx.tasks.task_definitions, task)
end

# register cron expression for this route
if !isnothing(cron) && !isempty(cron)
job = CronDefinition(path, httpmethod, cron)
push!(ctx.cron.job_definitions, job)
end

combinedtags = [tags..., routertags...]

# register tags
if !haskey(ctx.docs.taggedroutes, path)
ctx.docs.taggedroutes[path] = TaggedRoute([httpmethod], combinedtags)
else
combinedmethods = vcat(httpmethod, ctx.docs.taggedroutes[path].httpmethods)
ctx.docs.taggedroutes[path] = TaggedRoute(combinedmethods, combinedtags)
end

#return path
return path
end
end
merge!(schema, updated_schema)
end


"""
Returns the openapi equivalent of each Julia type
"""
function gettype(type::Type) :: String
function gettype(type::Type)::String
if type <: Bool
return "boolean"
elseif type <: AbstractFloat
return "number"
elseif type <: Integer
elseif type <: Integer
return "integer"
elseif type <: AbstractVector
return "array"
elseif type <: String || type == Date || type == DateTime
return "string"
elseif isstructtype(type)
return "object"
else
else
return "string"
end
end
Expand All @@ -227,20 +58,20 @@ end
Returns the specific format type for a given parameter
ex.) DateTime(2022,1,1) => "date-time"
"""
function getformat(type::Type) :: Nullable{String}
function getformat(type::Type)::Nullable{String}
if type <: AbstractFloat
if type == Float32
return "float"
elseif type == Float64
return "double"
end
elseif type <: Integer
elseif type <: Integer
if type == Int32
return "int32"
elseif type == Int64
return "int64"
end
elseif type == Date
elseif type == Date
return "date"
elseif type == DateTime
return "date-time"
Expand All @@ -249,7 +80,6 @@ function getformat(type::Type) :: Nullable{String}
end



"""
Used to generate & register schema related for a specific endpoint
"""
Expand All @@ -258,9 +88,9 @@ function registerschema(docs::Documenation, path::String, httpmethod::String, pa
params = []
for (name, type) in parameters
format = getformat(type)
param = Dict(
param = Dict(
"in" => "path",
"name" => "$name",
"name" => "$name",
"required" => true,
"schema" => Dict(
"type" => gettype(type)
Expand All @@ -274,8 +104,8 @@ function registerschema(docs::Documenation, path::String, httpmethod::String, pa

# lookup if this route has any registered tags
if haskey(docs.taggedroutes, path) && httpmethod in docs.taggedroutes[path].httpmethods
tags = docs.taggedroutes[path].tags
else
tags = docs.taggedroutes[path].tags
else
tags = []
end

Expand Down Expand Up @@ -331,21 +161,23 @@ function registerschema(docs::Documenation, path::String, httpmethod::String, pa
mergeschema(docs.schema, cleanedpath, route)
end


"""
Read in a static file from the /data folder
"""
function readstaticfile(filepath::String) :: String
function readstaticfile(filepath::String)::String
path = joinpath(DATA_PATH, filepath)
return read(path, String)
end

function redochtml(schemapath::String, docspath::String) :: HTTP.Response

function redochtml(schemapath::String, docspath::String)::HTTP.Response
redocjs = readstaticfile("$REDOC_VERSION/redoc.standalone.js")

html("""
<!DOCTYPE html>
<html lang="en">
<head>
<title>Docs</title>
<meta charset="utf-8"/>
Expand All @@ -358,15 +190,16 @@ function redochtml(schemapath::String, docspath::String) :: HTTP.Response
<redoc spec-url="$schemapath"></redoc>
<script>$redocjs</script>
</body>
</html>
""")
end


"""
Return HTML page to render the autogenerated docs
"""
function swaggerhtml(schemapath::String, docspath::String) :: HTTP.Response
function swaggerhtml(schemapath::String, docspath::String)::HTTP.Response

# load static content files
swaggerjs = readstaticfile("$SWAGGER_VERSION/swagger-ui-bundle.js")
Expand Down
6 changes: 5 additions & 1 deletion src/constants.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export PACKAGE_DIR, DATA_PATH,
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE,
HTTP_METHODS,
WEBSOCKET, STREAM,
SPECIAL_METHODS, METHOD_ALIASES, TYPE_ALIASES
SPECIAL_METHODS, METHOD_ALIASES, TYPE_ALIASES,
SWAGGER_VERSION, REDOC_VERSION

# Generate a reliable path to our package directory
const PACKAGE_DIR = @path @__DIR__
Expand Down Expand Up @@ -45,4 +46,7 @@ const TYPE_ALIASES = Dict{String, Type}(
STREAM => HTTP.Streams.Stream
)

const SWAGGER_VERSION = "[email protected]"
const REDOC_VERSION = "[email protected]"

end
1 change: 1 addition & 0 deletions src/context.jl
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ end
server :: Ref{Nullable{Server}} = Ref{Nullable{Server}}(nothing)
router :: Router = Router()
custommiddleware :: Dict{String, Tuple} = Dict{String, Tuple}()
middleware_cache :: Dict{String, Function} = Dict{String, Function}()
history :: History = History(1_000_000)
history_lock :: ReentrantLock = ReentrantLock()
end
Expand Down
7 changes: 5 additions & 2 deletions src/core.jl
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ include("types.jl"); @reexport using .Types
include("constants.jl"); @reexport using .Constants
include("handlers.jl"); @reexport using .Handlers
include("context.jl"); @reexport using .AppContext
include("middleware.jl"); @reexport using .Middleware
include("routerhof.jl"); @reexport using .RouterHOF
include("cron.jl"); @reexport using .Cron
include("repeattasks.jl"); @reexport using .RepeatTasks
include("autodoc.jl"); @reexport using .AutoDoc
include("metrics.jl"); @reexport using .Metrics
include("reflection.jl"); @reexport using .Reflection
include("extractors.jl"); @reexport using .Extractors

export start, serve, serveparallel, terminate,

export start, serve, serveparallel, terminate,
internalrequest, staticfiles, dynamicfiles

oxygen_title = raw"""
Expand Down Expand Up @@ -235,7 +238,7 @@ application and have them execute in the order they were passed (left to right)
function setupmiddleware(ctx::Context; middleware::Vector=[], docs::Bool=true, metrics::Bool=true, serialize::Bool=true, catch_errors::Bool=true, show_errors=true) :: Function

# determine if we have any special router or route-specific middleware
custom_middleware = hasmiddleware(ctx.service.custommiddleware) ? [compose(ctx.service.router, middleware, ctx.service.custommiddleware)] : reverse(middleware)
custom_middleware = !isempty(ctx.service.custommiddleware) ? [compose(ctx.service.router, middleware, ctx.service.custommiddleware, ctx.service.middleware_cache)] : reverse(middleware)

# Docs middleware should only be available at runtime when serve() or serveparallel is called
docs_middleware = docs && !isnothing(ctx.docs.router[]) ? [DocsMiddleware(ctx.docs.router[], ctx.docs.docspath[])] : []
Expand Down
2 changes: 1 addition & 1 deletion src/methods.jl
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ function router(prefix::String = "";
interval::Nullable{Real} = nothing,
cron::Nullable{String} = nothing)

return Oxygen.Core.AutoDoc.router(CONTEXT[], prefix; tags, middleware, interval, cron)
return Oxygen.Core.router(CONTEXT[], prefix; tags, middleware, interval, cron)
end


Expand Down
Loading

0 comments on commit f09e6da

Please sign in to comment.