Read this in English or Português (BR).
A route binds a URL path + HTTP method to a callback. This document covers everything Horse can express in a route definition.
For the API of the request and response objects passed to each callback, see Request & Response.
THorse exposes one method per HTTP verb. Each takes a path string and a callback:
THorse.Get ('/ping', procedure(Req: THorseRequest; Res: THorseResponse) begin Res.Send('pong'); end);
THorse.Post ('/items', procedure(Req: THorseRequest; Res: THorseResponse) begin Res.Send('created'); end);
THorse.Put ('/items/:id', ...);
THorse.Patch ('/items/:id', ...);
THorse.Delete('/items/:id', ...);
THorse.Head ('/items/:id', ...);Method-routing is exact: THorse.Get only matches GET requests to that path. A request with the wrong method on a known path returns 405 Method Not Allowed. A request with an unknown path returns 404 Not Found.
Use a colon-prefixed segment to capture part of the URL:
THorse.Get('/users/:id',
procedure(Req: THorseRequest; Res: THorseResponse)
begin
Res.Send('User: ' + Req.Params['id']);
end);GET /users/42→User: 42(andReq.Params['id'] = '42')GET /users/→404GET /users→404
Multiple parameters in a single path work the same way:
THorse.Get('/teams/:teamId/members/:memberId',
procedure(Req: THorseRequest; Res: THorseResponse)
var
T, M: string;
begin
T := Req.Params['teamId'];
M := Req.Params['memberId'];
Res.Send(Format('Team %s, member %s', [T, M]));
end);Path parameters are always strings. Convert them yourself with StrToInt, TryStrToInt, etc.
Query strings are accessed via Req.Query:
// GET /search?name=Horse&category=framework
THorse.Get('/search',
procedure(Req: THorseRequest; Res: THorseResponse)
begin
Res.Send('Looking for ' + Req.Query['name'] + ' in ' + Req.Query['category']);
end);Missing keys return an empty string. If you need to know whether a key was present, use Req.Query.TryGetValue (returns False when absent).
Group related routes under a common prefix:
THorse.Group.Prefix('/api/v1')
.Get ('/users', ListUsers)
.Post ('/users', CreateUser)
.Get ('/users/:id', GetUser)
.Put ('/users/:id', UpdateUser)
.Delete('/users/:id', DeleteUser);The above is identical to writing THorse.Get('/api/v1/users', ...), etc. Groups can carry their own middleware:
THorse.Group
.Use(JWT(SECRET)) // middleware applies to everything in this group
.Prefix('/api/v1/admin')
.Get ('/stats', GetStats)
.Post('/audit', WriteAudit);You can pass an array of route-specific middlewares (array of THorseCallback) to apply checks only to a single endpoint:
// Static Syntax with array of middlewares
THorse.Get('/admin/dashboard', [AuthMiddleware, LoggerMiddleware],
procedure(Req: THorseRequest; Res: THorseResponse)
begin
Res.Send('Admin Dashboard');
end);
// Fluent Route Syntax with array of middlewares
THorse.Route('/reports')
.Get([AuthMiddleware, LoggerMiddleware], GetReportsHandler)
.Post([AuthMiddleware], PostReportHandler);Route-level middlewares execute after global middlewares and after any group-level middlewares, but right before the final route callback (handler) executes.
THorse.Use(...) registers middleware that runs on every request, regardless of path:
THorse.Use(MyLogger); // every request is logged
THorse.Use('/api', RequireAuth); // only /api/* requires authSee Middleware for the full story.
Internally, routes are stored by method. The enum lives in Horse.Commons:
type
TMethodType = (mtAny, mtGet, mtPut, mtPost, mtHead, mtDelete, mtPatch);mtAny is the wildcard used by middleware (THorse.Use) — it matches any method.
Note:
OPTIONS,TRACE, andCONNECTare not inTMethodType. They route asmtAny(matching wildcard middleware only). TheHorse.CORSmiddleware uses this to interceptOPTIONSfor preflight handling. If you need to discriminate by raw verb in your own middleware, useReq.Method: stringorReq.RawWebRequest.Method.
- Case-sensitive path matching:
/Usersand/usersare different routes. - No trailing slash normalisation:
/usersand/users/are different routes. Decide your project convention and stick to it. - First-registered wins for identical patterns — registering
/users/:idtwice is a duplicate; the second registration emits a runtime error in recent Horse versions (Duplicate route detected: [GET] /users/:id). - Parameter segments cannot have multiple colons —
/users/:id:nameis invalid; use two segments instead (/users/:id/:name).
There's no built-in mount like Express, but you can express sub-resources with groups:
procedure RegisterUsersRoutes(AGroup: THorseCoreGroup);
begin
AGroup
.Get ('', ListUsers)
.Post ('', CreateUser)
.Get ('/:id', GetUser);
end;
// In main:
RegisterUsersRoutes(THorse.Group.Prefix('/api/v1/users'));The same RegisterUsersRoutes can be mounted under multiple prefixes (/api/v1/users and /api/v2/users) without duplication — useful for API versioning when v2 only adds new endpoints.
Horse doesn't ship a printRoutes() helper, but you can iterate the router tree directly. The simpler approach is to print each route as you register it:
procedure RegisterAndLog(const Method, Path: string; Cb: THorseCallback);
begin
case Method of
'GET': THorse.Get (Path, Cb);
'POST': THorse.Post (Path, Cb);
// ...
end;
WriteLn(Method, ' ', Path);
end;For non-trivial apps, keep the route registration centralised in one unit so the layout is obvious from a single file.
For large-scale applications or high-performance APIs with hundreds of routes, Horse optionally includes a routing engine based on a Prefix Tree (Radix Tree).
Unlike the default linear router which performs path resolution in
Simply invoke the static class procedure THorse.UseRadixRouter at the very beginning of your application setup, before registering any route handlers:
begin
THorse.UseRadixRouter;
THorse.Get('/ping',
procedure(Req: THorseRequest; Res: THorseResponse)
begin
Res.Send('pong');
end);
THorse.Listen(9000);
end.Starting from this version, Horse natively supports URL path routing restrictions based on Regular Expressions and optional path parameters across all routers (THorseRouterTree and THorseRadixRouter).
By adding a question mark ? at the end of a path parameter (e.g., :id?), you instruct Horse that this parameter might be missing in the requested URL:
THorse.Get('/users/:id?',
procedure(Req: THorseRequest; Res: THorseResponse)
begin
if Req.Params.Items['id'].IsEmpty then
Res.Send('Listing all users')
else
Res.Send('Retrieving user ' + Req.Params.Items['id']);
end);- Requesting
GET /users/123-> Match! ReturnsRetrieving user 123(Req.Params.Items['id'] = '123') - Requesting
GET /users-> Match! ReturnsListing all users(Req.Params.Items['id'] = '')
You can restrict parameter matching by passing a Regex pattern enclosed in parentheses right after the parameter name:
// This route will only match if the 'id' parameter contains only decimal digits
THorse.Get('/users/:id(\d+)',
procedure(Req: THorseRequest; Res: THorseResponse)
begin
Res.Send('Numeric user: ' + Req.Params.Items['id']);
end);- Requesting
GET /users/456-> Match! ReturnsNumeric user: 456 - Requesting
GET /users/john-> Does not match the Regex constraint (continues matching other paths or returns404 Not Found).
The routing engine resolves route ambiguities by matching routes in order of specificity (from most specific to most generic):
- Exact Static Route: e.g.,
GET /users/new - Regex Parametric Route: e.g.,
GET /users/:id(\d+) - Optional / General Parametric Route: e.g.,
GET /users/:id?
- Request & Response — what you do inside the callback.
- Middleware —
THorse.Use, theNextproc, registration order. - Providers — how the transport layer hands the request to your route callback.