Serving web apps

This tutorial was generated using Literate.jl. Download the source as a .jl file.

This tutorial demonstrates how to set up and serve JuMP models via a REST API using HTTP.jl and JSON.jl. The example service accepts an HTTP POST request with a JSON body, solves a parameterized mixed-integer program, and returns the solution as JSON.

Learning intentions:

  • Structure a JuMP solver as a self-contained endpoint function that validates JSON input, builds and solves a model, and returns results as a dictionary
  • Wrap the endpoint in an HTTP request handler that parses JSON, runs the solver in a separate thread, and returns an HTTP response
  • Register the endpoint on an HTTP router, start the server, and send test requests to verify the full round-trip

Required packages

This tutorial uses the following packages:

using JuMP
import HiGHS
import HTTP
import JSON

The server side

The core components of our REST server are endpoints. These are functions which accept a Dict{String,Any} of input parameters, and return a Dict{String,Any} as output. The types are Dict{String,Any} because we're going to read these to and from JSON.

Here's a very simple endpoint: it accepts params as input, formulates and solves a trivial mixed-integer program, and then returns a dictionary with the result.

function endpoint_solve(params::Dict{String,Any})
    if !haskey(params, "lower_bound")
        return Dict{String,Any}(
            "status" => "failure",
            "reason" => "missing lower_bound param",
        )
    elseif !(params["lower_bound"] isa Real)
        return Dict{String,Any}(
            "status" => "failure",
            "reason" => "lower_bound is not a number",
        )
    end
    model = Model(HiGHS.Optimizer)
    set_silent(model)
    @variable(model, x >= params["lower_bound"], Int)
    optimize!(model)
    ret = Dict{String,Any}(
        "status" => "okay",
        "terminaton_status" => termination_status(model),
        "primal_status" => primal_status(model),
    )
    # Only include the `x` key if it has a value.
    if primal_status(model) == FEASIBLE_POINT
        ret["x"] = value(x)
    end
    return ret
end
endpoint_solve (generic function with 1 method)

When we call this, we get:

endpoint_solve(Dict{String,Any}("lower_bound" => 1.2))
Dict{String, Any} with 4 entries:
  "status"            => "okay"
  "x"                 => 2.0
  "primal_status"     => FEASIBLE_POINT
  "terminaton_status" => OPTIMAL
endpoint_solve(Dict{String,Any}())
Dict{String, Any} with 2 entries:
  "status" => "failure"
  "reason" => "missing lower_bound param"

We now need to turn each endpoint into a function that accepts an HTTP.Request, parses the JSON input, runs the endpoint, converts the result to JSON, and returns an HTTP.Response. In addition, the computation is handled in a separate thread, and we catch any unhandled exceptions.

function wrap_endpoint(endpoint::Function)
    function serve_request(request::HTTP.Request)::HTTP.Response
        task = Threads.@spawn try
            input = request.body |> String |> JSON.parse
            ret = convert(Dict{String,Any}, input) |> endpoint |> JSON.json
            HTTP.Response(200, ret)
        catch err
            HTTP.Response(500, "internal error: $err")
        end
        return fetch(task)
    end
end
wrap_endpoint (generic function with 1 method)

Finally, we need an HTTP server. There are a variety of ways you can do this in HTTP.jl. Here's one way:

router = HTTP.Router()
# Register other routes as needed
HTTP.register!(router, "/api/solve", wrap_endpoint(endpoint_solve))
server = HTTP.serve!(router, HTTP.ip"127.0.0.1", 8080)
HTTP.Servers.Server{HTTP.Servers.Listener{Nothing, Sockets.TCPServer}}(HTTP.Servers.Listener{Nothing, Sockets.TCPServer}(Sockets.InetAddr{Sockets.IPv4}(ip"127.0.0.1", 8080), "127.0.0.1", "8080", nothing, Sockets.TCPServer(RawFD(28) active)), nothing, Set{HTTP.Connections.Connection}(), Task (runnable, started) @0x00007fa76575c970, ReentrantLock())

The client side

Now that we have a server, we can send it requests via this function:

function send_request(data::Dict; endpoint::String = "solve")
    ret = HTTP.request(
        "POST",
        # This should match the URL and endpoint we defined for our server.
        "http://127.0.0.1:8080/api/$endpoint",
        ["Content-Type" => "application/json"],
        JSON.json(data),
    )
    if ret.status != 200
        # This could happen if there are time-outs, network errors, etc.
        return Dict(
            "status" => "failure",
            "code" => ret.status,
            "body" => String(ret.body),
        )
    end
    return JSON.parse(String(ret.body))
end
send_request (generic function with 1 method)

Let's see what happens:

send_request(Dict("lower_bound" => 0))
JSON.Object{String, Any} with 4 entries:
  "primal_status"     => "FEASIBLE_POINT"
  "status"            => "okay"
  "terminaton_status" => "OPTIMAL"
  "x"                 => 0.0
send_request(Dict("lower_bound" => 1.2))
JSON.Object{String, Any} with 4 entries:
  "primal_status"     => "FEASIBLE_POINT"
  "status"            => "okay"
  "terminaton_status" => "OPTIMAL"
  "x"                 => 2.0

If we don't send a lower_bound, we get:

send_request(Dict("invalid_param" => 1.2))
JSON.Object{String, Any} with 2 entries:
  "reason" => "missing lower_bound param"
  "status" => "failure"

If we don't send a lower_bound that is a number, we get:

send_request(Dict("lower_bound" => "1.2"))
JSON.Object{String, Any} with 2 entries:
  "reason" => "lower_bound is not a number"
  "status" => "failure"

Finally, we can shutdown our HTTP server:

close(server)
[ Info: Server on 127.0.0.1:8080 closing

Next steps

For more complicated examples relating to HTTP servers, consult the HTTP.jl documentation.

To see how you can integrate this with a larger JuMP model, read Design patterns for larger models.