REST application adapter¶
EasyLocal::REST is an optional Crow-based application adapter. It exposes a
configured EasyLocal app as a generic Crow Blueprint while keeping HTTP,
JSON, Crow, and server lifecycle completely outside EasyLocal::Core.
The design follows the same rule used by the TextUI:
one run owns freshly bound, mutable EasyLocal services; only the immutable Input may be shared across runs.
This keeps concurrency at the application boundary instead of making the bound app, SolutionManager, neighborhoods, algorithms, or Runner state internally synchronized.
Enabling the component¶
REST is disabled by default:
cmake -S . -B build/rest \
-DEASYLOCAL_ENABLE_REST=ON
EasyLocal first looks for Crow 1.3 through find_package. If it is unavailable,
a pinned Crow v1.3.3 plus standalone Asio 1.38.2 may be fetched only when
EASYLOCAL_FETCH_DEPENDENCIES=ON is explicitly enabled.
Installed consumers request the component explicitly:
find_package(EasyLocal CONFIG REQUIRED COMPONENTS Core REST)
target_link_libraries(my_server PRIVATE EasyLocal::REST)
A consumer requesting only Core neither creates EasyLocal::REST nor loads
Crow/Asio.
Generic app Blueprint¶
The REST adapter intentionally does not own a Crow server. It produces one Blueprint per EasyLocal application. Multiple applications can therefore be mounted into the same Crow process under distinct prefixes:
auto assignment_api = easylocal::rest::blueprint(
"/assignment", assignment_app, AssignmentCodec{});
auto tsp_api = easylocal::rest::blueprint(
"/tsp", tsp_app, TspCodec{});
crow::SimpleApp server;
server.register_blueprint(assignment_api.crow_blueprint());
server.register_blueprint(tsp_api.crow_blueprint());
server.bindaddr("127.0.0.1")
.port(8080)
.multithreaded()
.run();
Port, middleware, HTTP concurrency, logging, and process lifecycle remain normal Crow concerns.
A problem with the text hooks of Apps and tools
(read_input, write_solution, read_solution) needs no codec:
blueprint(prefix, app, options) serves it with text_codec, and the Input and
the solutions are JSON strings in the text of the hooks, the costs JSON numbers
(the text of cost::to_text for a structured cost).
auto api = easylocal::rest::blueprint("/tsp", tsp_app);
// POST /tsp/runners/sa/runs {"input": "5\n0 2 9 10 7\n...", "initial_solution": "0 1 2 3 4"}
A codec supplies a JSON boundary of its own; each value it does not handle goes through the problem's text hook, as above:
struct Codec
{
// Or read_input, from a JSON string.
Input decode_input(const crow::json::rvalue& value) const;
// Or write_solution, to a JSON string.
crow::json::wvalue encode_solution(const Input& input, const Solution& solution) const;
// Or a JSON number, or the text of cost::to_text.
crow::json::wvalue encode_cost(const Cost& cost) const;
// Optional, or read_solution. Called only when the request contains
// initial_solution.
Solution decode_initial_solution(const Input& input, const crow::json::rvalue& value) const;
// Optional. Called only when the request contains a target that is not a
// string; without it a target is a JSON number, for arithmetic costs only.
Cost decode_cost(const crow::json::rvalue& value) const;
};
The framework owns only the request envelope. input and initial_solution are
otherwise opaque JSON values passed unchanged to the codec. A codec may therefore
accept a structured JSON object/array, a JSON string containing an existing text
file representation, or any other JSON representation appropriate to the
problem. Through a text hook, a value that is not a string, or a text the hook
cannot read, is rejected with 422. A decoded initial_solution must be valid for the Input (the
SolutionManager's is_valid), or the request is rejected with 422. If
initial_solution is omitted, the run starts as its start says (below).
The generic Blueprint is mounted explicitly:
auto api = easylocal::rest::blueprint(
"/assignment",
application,
AssignmentCodec{},
{
.workers = 4,
.queue_capacity = 32,
.completed_run_capacity = 64,
});
crow::SimpleApp server;
server.register_blueprint(api.crow_blueprint());
app_blueprint is deliberately non-copyable/non-movable because its Crow route
callbacks refer to its state. Keep it alive for at least as long as the Crow
application uses the registered Blueprint. Codec calls are serialized by the
adapter, so a codec need not provide its own synchronization.
The Assignment REST example demonstrates both supported input styles: a structured
object with demand/capacity and a JSON string containing the existing textual
assignment-instance representation.
Run creation envelope¶
For a Blueprint mounted at /assignment, run creation is:
POST /assignment/runners/fi/runs
Content-Type: application/json
with a generic envelope:
{
"input": {
"demand": [4, 4, 2],
"capacity": [5, 5]
}
}
or, for a codec that accepts the existing text representation:
{
"input": "3 2 4 3 2 5 5"
}
An application codec may additionally support:
{
"input": { "...": "..." },
"initial_solution": { "...": "..." }
}
Without an initial_solution, the envelope may say where the run starts:
"start": "random", the SolutionManager's random_solution(), drawn from the
run's seed, or "start": "initial", its initial_solution(). Without either,
a run starts from a random solution when the problem has them, from the
initial one otherwise, as cli::run does; run resources report the start
("solution" for a given one). Both fields together, or a start the problem
cannot build, are rejected with 422.
{
"input": { "...": "..." },
"start": "initial"
}
The envelope may also carry a seed, a non-negative integer. Stochastic
runners receive an RNG seeded with it; deterministic runners ignore it. Without
an explicit seed a run uses blueprint_options::seed + <run id>, so runs differ
but stay reproducible. Status responses report the seed used.
{
"input": { "...": "..." },
"seed": 12345
}
A target cost stops the run as soon as its best cost is at least as good as
the target, a lower bound for example (stop_at in C++; every runner honours
it). A JSON string is a cost in the syntax of the command line and the
TextUI: a number, "[hard, soft]", or the problem's own notation when it
provides read_cost (Costs as text). Otherwise, for
an arithmetic cost the target is a JSON number, an integer for integral costs,
within the range of the cost type (422 otherwise); for other costs the codec decodes it with decode_cost, and without one a
request with a target is rejected with 422. Run resources repeat the target,
encoded by encode_cost.
{
"input": { "...": "..." },
"target": 426
}
A timeout, in seconds (a non-negative JSON number), stops the run once it has
passed (timeout in C++), and max_evaluations, a non-negative integer, once
it has made that many evaluations (max_evaluations in C++; a runner's own,
if smaller, still applies); run resources repeat them. Any other value is
rejected with 422. A service with blueprint_options::max_timeout bounds
the time of every run: a longer timeout is rejected with 422, and a run
without one gets max_timeout (GET / reports it).
{
"input": { "...": "..." },
"timeout": 2.5,
"max_evaluations": 100000
}
A run may change the app's parameters, for itself only: the same paths as
the command line and the TextUI (runners.<name>.*, cost.*,
solution_manager.*, neighborhood.*), listed by GET /parameters. Nested objects and dotted keys
are equivalent, and may be mixed:
{
"input": { "...": "..." },
"parameters": {
"runners": { "sa": { "temperature": { "cooling_rate": 0.9 } } },
"cost.weights": [1, 20]
}
}
Values are JSON numbers, booleans, strings, or arrays of numbers, booleans
and such arrays ([[1, 2], [3]]), as in a TOML file; a string inside an array
is rejected, since list elements are not quoted. A string is read as the
parameter's text, so the values that GET /parameters lists can be
sent back as they are. They are applied all or none before the run's initial
solution is built: an unknown path or an invalid value rejects the request
with 422 and the code invalid_parameters, whose message names each path.
Run resources repeat the parameters they were submitted with, by path and as
text ("parameters": {"runners.sa.temperature.cooling_rate": "0.9"}), so that
a run can be repeated together with its seed.
A successful submission returns 202 Accepted, sets Location to the run
resource, and uses id consistently:
{
"id": "42",
"runner": "fi",
"seed": 42,
"status": "queued",
"cancellation_requested": false,
"progress": {
"evaluations": 0,
"iterations": 0
}
}
Routes and lifecycle¶
The generic surface under a chosen prefix is:
| Method | Route | Meaning |
|---|---|---|
GET |
/assignment/ |
application/executor metadata |
GET |
/assignment/runners |
registered runner names |
GET |
/assignment/parameters |
the app's parameters: path, description, value (as text), kind (boolean, integer, real, limit, text, path, list), domain (as text, such as [1, unlimited]; absent when none is declared), active (whether its condition holds) and condition (as text, with full paths; absent when it has none) |
POST |
/assignment/runners/<runner>/runs |
enqueue a run |
GET |
/assignment/runs/<id> |
inspect status/progress |
GET |
/assignment/runs/<id>/solution |
retrieve terminal solution and cost (and the front, with a Pareto cost) |
POST |
/assignment/runs/<id>/cancel |
request cooperative cancellation |
DELETE |
/assignment/runs/<id> |
forget a terminal run |
Runs move through:
stateDiagram-v2
direction LR
[*] --> queued
queued --> running
running --> succeeded
running --> failed
queued --> cancelled
running --> cancelled
succeeded --> [*]
failed --> [*]
cancelled --> [*]
Cancellation and deletion deliberately have different semantics. POST
/runs/<id>/cancel requests cooperative stop and returns 202; the resource
remains queryable so the client can observe cancelled and retrieve a partial
solution. A queued run is cancelled at once, without a solution, and leaves
its place in the queue. DELETE /runs/<id> is permitted only after the run is
terminal and returns 204 No Content. Deleting an active run returns 409.
Every runner is cancellable: the control is carried by the framework-owned
search_run, so no per-runner capability is advertised.
Status and result shape¶
GET /runs/<id> has the same generic shape for every problem (an
Experimental surface, as Stability says of the adapters):
{
"id": "42",
"runner": "fi",
"seed": 42,
"status": "running",
"cancellation_requested": false,
"progress": {
"evaluations": 237,
"iterations": 14,
"evaluation_limit": 2000,
"best": 31
}
}
evaluation_limit is omitted when the Runner does not report one. best is
the best cost found so far, encoded as cost is (by the codec's
encode_cost, else as a number or as text); it is omitted until the run has
evaluated its first solution, and is the best of all the runs of a
MultiStart or a pipeline (of the stages on the app's cost). Once the run
has succeeded, or was cancelled with a partial solution, the status also gives
its final progress, its cost and "solution_url", the address of GET
/runs/<id>/solution, and termination: why the run ended (completed,
local_optimum, evaluation_budget_exhausted, cancelled, target_reached,
idle_limit_reached, time_limit_reached). A
run is cancelled when its termination says so: one whose cancellation came
after it ended on its own terms is succeeded, as is the run of a custom
runner whose result has no termination (completed). A failed run gives
"error": {"code": "run_failed", "message": ...} instead.
{
"id": "42",
"runner": "fi",
"seed": 42,
"status": "succeeded",
"cancellation_requested": false,
"progress": { "evaluations": 2000, "iterations": 118, "evaluation_limit": 2000, "best": 27 },
"termination": "evaluation_budget_exhausted",
"cost": { "...": "..." },
"solution_url": "/assignment/runs/42/solution"
}
GET /runs/<id>/solution gives the solution and its cost, whose values
remain problem-specific and are produced by the codec, with the request the
run was submitted with (seed, and target, timeout, max_evaluations and
parameters when it had them):
{
"id": "42",
"runner": "fi",
"seed": 42,
"max_evaluations": 2000,
"parameters": { "runners.fi.max_evaluations": "5000" },
"status": "succeeded",
"cost": { "...": "..." },
"solution": { "...": "..." }
}
Before the run ends it answers 409 result_not_ready; for a run that ended
without a solution, cancelled while it was queued, 409 no_solution; for a
failed run, 409 run_failed.
With a cost::pareto cost the result also has front, the non-dominated
solutions the run reached, ordered by their objectives, each with its cost,
encoded as cost and solution are (by the codec or the text hooks):
{
"id": "42",
"runner": "plahc",
"status": "succeeded",
"solution": { "...": "..." },
"cost": "[0, 9]",
"front": [
{ "cost": "[0, 9]", "solution": { "...": "..." } },
{ "cost": "[1, 8]", "solution": { "...": "..." } }
]
}
A result without a front, with any other cost, has no front field.
A cooperatively cancelled run may expose the same result shape with
"status": "cancelled"; the solution is then the valid partial solution
returned by the controlled Runner.
Error mapping¶
Every response with an error status (4xx, 5xx) has the same envelope; the
status of a failed run, a 200 response, carries one too, under error:
{
"error": {
"code": "unknown_runner",
"message": "runner 'missing' is not registered"
}
}
The generic mapping is:
| HTTP | Meaning |
|---|---|
400 |
syntactically invalid JSON, or arrays and objects nested deeper than 64 levels (invalid_json) |
404 |
unknown runner or run (unknown_runner, run_not_found) |
409 |
valid operation in the wrong run state/capability (result_not_ready, run_not_terminal, run_not_active), the solution of a run that ended without one (no_solution), or of a run that failed (run_failed) |
422 |
valid JSON but invalid run envelope/domain data (invalid_run_request), or parameters that do not apply (invalid_parameters) |
503 |
bounded solver queue full (queue_full) |
500 |
unexpected adapter/application failure (internal_error) |
Codec/domain validation should throw std::invalid_argument for client-supplied
semantic errors; those are mapped to 422. Other unexpected exceptions while
creating a run are treated as internal failures.
Bounded completed-run retention¶
The waiting queue and completed-run history are independently bounded.
blueprint_options::completed_run_capacity defaults to 64 and must be positive.
Only terminal records participate in history eviction; queued/running runs are
never evicted. Once the bound is exceeded, the oldest terminal record is removed.
Clients may free a terminal record earlier with DELETE /runs/<id>.
This retention is intentionally an in-memory convenience for result/status retrieval, not a persistent history system. Durable audit/history belongs to the surrounding web/infrastructure layer.
Concurrency model¶
Crow concurrency and solver concurrency are intentionally separate:
flowchart TB
crow["Crow HTTP workers<br/>parse / route / enqueue / reply"]
pool["bounded REST execution pool"]
crow --> pool
pool --> runA["run A"] --> svcA["services<br/>(fresh, one set per run)"]
pool --> runB["run B"] --> svcB["services<br/>(fresh, one set per run)"]
pool --> runC["run C"] --> svcC["services<br/>(fresh, one set per run)"]
svcA & svcB & svcC --> input[("immutable Input")]
Crow request threads do not execute a CPU-bound local search to completion. They decode the request, materialize the immutable Input and initial Solution, enqueue work, and return the run identifier.
Each accepted job owns a Session on the run's Input, created when the run is
submitted: configured with the run's parameters, then bound to the Input once,
with the initial solution and the run's seed. The worker calls
session.run("name", with(control)), which runs on the services of that
Session, bound for that run alone,
and stores the resulting solution and its cost, encoded once for every status
poll. Mutable SolutionManager, neighborhood,
algorithm, Runner, RNG, and Solution state therefore belongs to that run only.
No mutex is added to those Core objects and no Clone() protocol is required.
The default worker count is max(1, hardware_concurrency() - 1) and the default
waiting-queue capacity is 64; both are adapter policy, configurable per
Blueprint, and must be positive: the Blueprint's constructor rejects a zero
with std::invalid_argument before any worker starts.
Cooperative control and TextUI¶
Core exposes a small std-only control object:
std::stop_source stop;
auto observer = [](const easylocal::run_progress& progress) { /* observe */ };
easylocal::run_control control{stop.get_token(), observer};
The observer is non-owning and valid only for the controlled run. It is passed
as the trailing run option, run(solution, ..., easylocal::with(control)).
control.observe_best_cost<Cost>(best) adds a second observer, called with
each better cost of type Cost the run finds (a solver's runs together: a
cost better than those of the runs before). shared_run_progress and
shared_best_cost<Cost> keep the last report for another thread: the REST
service stores them in each run and reads them for each status, the TextUI
for its progress line.
Algorithms never see the control directly: search_run::should_stop() checks it
and the search_run primitives report progress, so every runner honours
cancellation through the same contract. A run without an explicit control uses
an empty run_control, whose checks reduce to null tests.
TextUI and REST therefore share the same architectural contract without sharing a threading subsystem:
Core: fresh services per run: Session::run, app.run("name", ...)
TextUI: one background run owned by the frontend
REST: bounded pool of background runs owned by the adapter
Security boundary¶
EasyLocal::REST is not an Internet edge/security framework. TLS termination,
authentication, authorization, request-rate limiting, reverse-proxy policy,
ingress controls, and deployment hardening belong to the surrounding network
infrastructure.
The adapter still performs ordinary protocol/application validation and relies
on maintained Crow/Asio HTTP parsing. Queue/history bounds and max_timeout
are resource-control semantics, not a substitute for edge security. The
examples listen on 127.0.0.1 only, as a service should unless it is meant to
be reached from other hosts. Without max_timeout a client may ask for a run
without a time limit, which holds a worker until it ends or is cancelled. The
body size is left to Crow and the proxy in front of it.
No Crow, Asio, HTTP, or JSON type appears in a Core signature. The architecture
test also prevents optional adapters from reaching into easylocal/detail/*.
HTTP integration test¶
When REST is enabled on a Unix-like host with curl, CTest registers
easylocal.rest-http. It starts the real Assignment Crow example and exercises the
public surface with HTTP requests: discovery, malformed JSON, semantic 422
errors, structured and opaque-text input decoding, Location/run IDs, status
and nested progress, result retrieval, active-run delete rejection, cooperative
cancel, cancelled partial result, terminal deletion, and bounded retention.
The test carries both integration and rest-http labels.
scripts/build-and-test.sh --with-rest runs it explicitly even in the normal
fast profile; --integration additionally enables the remaining integration
tests such as the installed-package consumer.