Serving web apps

This tutorial demonstrates how to setup and serve JuMP models via a REST API.

In the example app we are building, we solve a trivial mixed-integer program, which is parameterized by the lower bound of a variable. To call the service, users send an HTTP POST request with JSON contents indicating the lower bound. The returned value is the solution of the mixed-integer program as JSON.

First, we need JuMP and a solver:

using JuMP
import HiGHS

We also need HTTP.jl to act as our REST server, and JSON.jl to marshall data.

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 has_values(model)
        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"

For a second function, we need a function that accepts an HTTP.Request object and returns an HTTP.Response object.

function serve_solve(request::HTTP.Request)
    data = JSON.parse(String(request.body))
    solution = endpoint_solve(data)
    return HTTP.Response(200, JSON.json(solution))
end
serve_solve (generic function with 1 method)

Finally, we need an HTTP server. There are a variety of ways you can do this in HTTP.jl. We use an explicit Sockets.listen so we have manual control of when we shutdown the server.

function setup_server(host, port)
    server = HTTP.Sockets.listen(host, port)
    @async HTTP.serve(host, port; server = server) do request
        try
            # Extend the server by adding other endpoints here.
            if request.target == "/api/solve"
                return serve_solve(request)
            else
                return HTTP.Response(404, "target $(request.target) not found")
            end
        catch err
            # Log details about the exception server-side
            @info "Unhandled exception: $err"
            # Return a response to the client
            return HTTP.Response(500, "internal error")
        end
    end
    return server
end
setup_server (generic function with 1 method)
Info

@async runs the server in a background process. If you omit @async, HTTP.serve will block the current Julia process.

Warning

HTTP.jl does not serve requests on a separate thread. Therefore, a long-running job will block the main thread, preventing concurrent users from submitting requests. To work-around this, read HTTP.jl issue 798 or watch Building Microservices and Applications in Julia from JuliaCon 2020.

server = setup_server(HTTP.ip"127.0.0.1", 8080)
Sockets.TCPServer(RawFD(26) active)

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))
Dict{String, Any} with 4 entries:
  "status"            => "okay"
  "x"                 => 0.0
  "primal_status"     => "FEASIBLE_POINT"
  "terminaton_status" => "OPTIMAL"
send_request(Dict("lower_bound" => 1.2))
Dict{String, Any} with 4 entries:
  "status"            => "okay"
  "x"                 => 2.0
  "primal_status"     => "FEASIBLE_POINT"
  "terminaton_status" => "OPTIMAL"

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

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

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

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

Finally, we can shutdown our HTTP server:

close(server)

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.


Tip

This tutorial was generated using Literate.jl. View the source .jl file on GitHub.