| name | dmvcframework-minimal-api |
| description | Use when building a DelphiMVCFramework service with lambda/anonymous-method routes instead of controller classes โ Minimal API. Triggers on "minimal API", "MapGet", "MapPost", "MapMethods", "route group", "endpoint filter", "HTTP filter", "TMVCRouteGroup", "AsWeb", "lambda routes", "no controller", "Prefix", "UseHTTPFilter", "TMVCFormFile", "MVCFromQueryString", "wizard Minimal API preset". Covers both Minimal REST APIs and Minimal web apps (TemplatePro/HTMX via .AsWeb). |
DMVCFramework โ Minimal API
Routes are anonymous methods registered on a route group, not methods of a controller class.
Same engine, same serializer, same ActiveRecord as the controller-based API โ only the routing layer differs.
Everything below is copied from sources/MVCFramework.MinimalAPI.pas, sources/MVCFramework.Filters.pas
and the samples. Do not invent names โ the DSL does not follow ASP.NET spelling (there is no MapGroup,
no app.Use, no OkResponse).
When in doubt about an API โ verify it, never guess
Never invent an identifier or answer from memory. If you need a signature this skill does not cover, ask the
user for the path to their DelphiMVCFramework checkout and read sources/ (and the matching samples/
project); failing that, read the official repository โ
sources ยท
samples.
If you still cannot verify it, say so. Where a skill and a sample disagree, the sample wins.
Delphi Language Target
Delphi 11 Alexandria or later โ inline var declarations and for var loops are fine, and this skill's
examples use them. Do not use Delphi 13 Florence-only syntax (NameOf, inline if expressions).
For the language and the RTL themselves โ version gating, lifetime, strings, generics, threading โ load the
delphi skill.
When to use
- New service where a controller class per resource adds no value
- Route-level filters/composition (auth per group, versioned prefixes)
- Web app with lambda handlers rendering TemplatePro views (
.AsWeb)
Use the controller-based API (skill dmvcframework) when you want class-level inheritance, OnBeforeAction
hooks, or the classic middleware chain as the primary mechanism.
REQUIRED REFERENCE โ dmvcframework-security. Any endpoint that accepts input from a client (body,
query string, header, cookie, upload, URL) must follow it: access control and IDOR, mass assignment,
SQL injection, XSS, CSRF, path traversal, uploads, security headers, JWT, secrets. Invoke it whenever you
write or review such an endpoint โ not only when the user says "security".
0. STOP โ start from a wizard project
Never create the project from scratch. Never hand-write the .dpr bootstrap.
The IDE wizard ships two Minimal API presets โ Minimal API RESTful and Minimal API WebApp โ and both
generate a correct, compiling project with the routing already wired. Your job is to add routes to it.
Step 1 โ detect it. The user is expected to have run the wizard and started the agent from inside the
project folder. A Minimal API wizard project has:
*.dpr Boot + RegisterServices + RunServer (Indy Direct by default; a WebModule here means WebBroker/ISAPI/Apache โ also fine)
BootConfigU.pas dotEnv + LoggerPro
EngineConfigU.pas ConfigureEngine โ view engine, exception handler, HTTP filters
RoutesU.pas ConfigureRoutes โ YOUR ROUTES GO HERE
ServicesU.pas DI registrations
EntitiesU.pas entities
bin/.env port and settings
Read RoutesU.pas and EngineConfigU.pas before writing anything, and follow their conventions.
Step 2 โ if there is no wizard project, stop. Do not scaffold one. Tell the user:
This skill works on a project generated by the DMVCFramework IDE wizard, and I do not see one here.
Please create it first:
Delphi IDE โ File โ New โ Other โ Delphi Projects โ DelphiMVCFramework โ New DMVCFramework Application
Pick Minimal API RESTful (or Minimal API WebApp for HTML pages). Accept the defaults โ the server
backend is Indy Direct. Compile and run it once. Then cd into the project folder, start me there, and
tell me which routes to add.
Then wait.
The host may be WebBroker (ISAPI, Apache) โ that is fine. Keep it, do not migrate it, do not suggest
migrating it. Everything above the host is identical on every backend. See dmvcframework,
reference/servers.md.
The section below documents the bootstrap the wizard generates, so you can read it โ not so you can
retype it.
1. Bootstrap โ what the wizard generated
// Project.dpr โ already written by the wizard. Do not recreate it.
begin
IsMultiThread := True;
MVCSerializeNulls := True;
Boot; // BootConfigU: dotEnv + LoggerPro
RegisterServices(DefaultMVCServiceContainer); // ServicesU
DefaultMVCServiceContainer.Build; // mandatory
RunServer(dotEnv.Env('dmvc.server.port', 8080));
end.
procedure RunServer(APort: Integer);
var
lEngine: TMVCEngine;
lServer: IMVCServer;
begin
lEngine := TMVCEngine.Create(
procedure(Config: TMVCConfig)
begin
Config[TMVCConfigKey.DefaultContentType] := TMVCMediaType.APPLICATION_JSON;
end);
try
ConfigureEngine(lEngine); // EngineConfigU: view engine, exception handler, HTTP filters
ConfigureRoutes(lEngine.Root); // RoutesU: takes a TMVCRouteGroup<TObject>, NOT the engine
lServer := TMVCServerFactory.CreateIndyDirect(lEngine);
lServer.RunAndWait(APort);
finally
lEngine.Free;
end;
end;
ConfigureRoutes signature used by the wizard/showcase projects:
procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
Ordering rule: add classic middlewares before the first MapXxx call. The minimal-API dispatcher is
installed lazily on the first Map and short-circuits matching routes โ middlewares added after it never run
for minimal routes.
2. Route registration DSL
Everything hangs off a group. There are no Map* methods on TMVCEngine.
// TMVCEngine helpers
function Root: TMVCRouteGroup<TObject>; // = Prefix('')
function Prefix(const APrefix: string): TMVCRouteGroup<TObject>; overload;
function Prefix<T: class>(const APrefix: string; const AData: T;
AOwns: Boolean = True): TMVCRouteGroup<T>; overload;
function UseHTTPFilter(const AFilter: TMVCHTTPFilter): TMVCEngine;
TMVCRouteGroup<T> โ a record with value semantics:
function Prefix(const APath: string): TMVCRouteGroup<T>; // nested; inherits filters + rkWeb
function Use(const AFilter: TMVCEndpointFilter): TMVCRouteGroup<T>;
function AsWeb: TMVCRouteGroup<T>; // web route: hidden from OpenAPI
function AsApi: TMVCRouteGroup<T>; // default
function MapGet (const APath: string; const AHandler: TMVCMinimalFunc): TMVCRouteHandle;
function MapPost (...) : TMVCRouteHandle;
function MapPut (...) : TMVCRouteHandle;
function MapPatch (...) : TMVCRouteHandle;
function MapDelete(...) : TMVCRouteHandle;
function MapMethods(const AVerbs: array of TMVCHTTPMethodType; const APath: string;
const AHandler: TMVCMinimalFunc): TMVCRouteHandle;
Each Map* (and MapMethods) has generic overloads for 1 to 4 typed handler arguments:
MapGet<T1>, MapGet<T1,T2>, MapGet<T1,T2,T3>, MapGet<T1,T2,T3,T4>.
Value-semantics trap: Use/Prefix/AsWeb return a new group.
lGroup.Use(Authorize); on its own line is a no-op. Chain it or reassign:
lGroup := lGroup.Use(Authorize);
Path syntax = standard DMVC: ($id), with optional constraint ($id:int).
Constraints: int, int64, float, bool, guid, date. A failed constraint means the route simply does
not match (โ 404 or the next route). Unknown constraint names are silently accepted.
Trailing wildcard: ($slug:*) captures the rest of the path, slashes included, as a string.
Route metadata โ TMVCRouteHandle (returned by every Map*)
lRoutes.MapGet('/customers', ...)
.WithName('customers.list') // unique engine-wide; duplicate/empty raises EMVCMinimalAPI
.WithSummary('List customers')
.WithDescription('...')
.WithTags(['customers'])
.WithDeprecated
.Produces<TCustomer>
.WithOpenAPI(False) // hide from the spec
.Use(RequireRole('admin')); // route-scoped filter, runs after the group's
3. Handler signatures
The only accepted shapes โ all return IMVCResponse, max 4 args, no procedure:
TMVCMinimalFunc = reference to function: IMVCResponse;
TMVCMinimalFunc<T1> = reference to function(Arg1: T1): IMVCResponse;
TMVCMinimalFunc<T1,T2> = reference to function(Arg1: T1; Arg2: T2): IMVCResponse;
TMVCMinimalFunc<T1,T2,T3> = reference to function(Arg1: T1; Arg2: T2; Arg3: T3): IMVCResponse;
TMVCMinimalFunc<T1,T2,T3,T4> = reference to function(Arg1: T1; Arg2: T2; Arg3: T3; Arg4: T4): IMVCResponse;
4. Parameter binding โ driven by the argument TYPE
Resolution order (TMVCMinimalArgResolver.Resolve<T>):
| Arg type | Bound from |
|---|
TWebContext | the request context |
TMVCFormFile | the first uploaded multipart file (nil if none) |
| interface | DI โ ServiceContainerResolver. Unresolvable โ 500 |
| record | hybrid binding, per-field (see below) |
| class | group data if the type matches; else POST/PUT/PATCH โ JSON body; else GET/DELETE โ writable properties filled from the query string |
primitive (Integer, Int64, string, Boolean, Double, TGUID, TDateTimeโฆ) | the next unconsumed route segment, in declaration order |
Two rules that surprise everyone:
- Only interfaces get DI. A concrete class argument is never a service โ it is body/query/group data.
- A primitive argument binds to a route segment, not to a query param.
?page=2 will not land in a
bare Integer arg. Use a record with [MVCFromQueryString].
Objects bound as arguments are owned by the framework and freed after the handler. Never free them.
Records โ field-level binding
type
TCustomerSearch = record
[MVCFromQueryString('q', '')] Query: string;
[MVCFromQueryString('page', 1)] Page: Integer;
[MVCFromQueryString('tag')] Tags: TArray<string>; // repeated ?tag=a&tag=b
[MVCFromHeader('X-Tenant')] Tenant: string;
[MVCFromCookie('sid', '')] SessionId: string;
City: string; // no attribute โ route segment, then query string, by field name
end;
TCreateCustomer = record
[MVCFromBody] Customer: TCustomer; // class field โ JSON body
end;
[MVCFromContentField('name')] โ form-urlencoded / multipart field. TArray<string> gives multi-value;
any other TArray<System.*> raises.
[MVCFromFile('field')] only renames the form field for a TMVCFormFile / TArray<TMVCFormFile> field โ
the binding itself is by type.
TMVCFormFile: FieldName, FileName, ContentType, Size: Int64, ContentStream: TStream
(request-owned โ do not free), ContentAsBytes, ContentAsString(AEncoding), SaveToFile(APath).
5. Return values โ standalone response builders
Handlers must return IMVCResponse. The builders are standalone functions (not controller methods, and
not named OkResponse/NotFoundResponse โ that is the controller-side naming):
Ok; Ok(Body: TObject; Owns: Boolean = True); Ok(Message: string);
Created(Location, Message); Created(Location, Body, Owns);
NoContent; Accepted; NotModified;
NotFound / BadRequest / Unauthorized / Forbidden / Conflict /
UnsupportedMediaType / UnprocessableEntity / InternalServerError // each ร3 overloads
Redirect(Location); Redirect(Location, Permanent, PreserveMethod = False);
Status(Code); Status(Code, Message); Status(Code, Body, Owns);
ProblemDetails(StatusCode, Title, Detail = '', Instance = '');
Ok(TObject) serializes and frees the object (Owns = True) โ works for TObjectList<T>,
entities, TJsonObject.
Ok(string) wraps the string as {"message": "..."}.
- Records cannot be returned. There is no
Ok(record) overload โ build a class or a TJsonObject.
- The result is mutable:
Result := Ok(lData); Result.StatusCode := 201;
- HTML: return a
TMVCHTMLResponse (set .HTMLBody) or use RenderView (ยง7).
6. Filters โ two kinds, do not mix them up
TMVCEndpointFilterNext = reference to function: IMVCResponse;
TMVCEndpointFilter = reference to function(const AContext: TWebContext;
const ANext: TMVCEndpointFilterNext): IMVCResponse;
TMVCHTTPFilterNext = reference to procedure;
TMVCHTTPFilter = reference to procedure(const AContext: TWebContext;
const ANext: TMVCHTTPFilterNext);
| EndpointFilter | HTTPFilter |
|---|
| Attached to | a group (group.Use) or one route (handle.Use) | the engine (lEngine.UseHTTPFilter) |
| Fires | only when a route matches | on every request, wrapping routing itself |
| Works with | IMVCResponse in/out | mutates Ctx.Response directly |
| Order | after all HTTPFilters; first Used = outermost | all HTTPFilters run before any EndpointFilter |
Skipping ANext() short-circuits the chain. Nested Prefix inherits the parent's filters.
Custom endpoint filter โ the whole shape:
function RequireLogin(const ARedirectTo: string): TMVCEndpointFilter;
begin
Result :=
function(const Ctx: TWebContext; const Next: TMVCEndpointFilterNext): IMVCResponse
begin
if Ctx.Session['user'].IsEmpty then
Result := Redirect(ARedirectTo)
else
Result := Next();
end;
end;
Ready-made filters (MVCFramework.Filters)
EndpointFilters โ MemorySession(TimeoutMinutes = 0; HttpOnly = False) ยท FileSession(...) ยท
DatabaseSession(...) ยท CORS(...) ยท JWT(AuthHandler, ClaimsSetup, Secret, LoginURLSegment, ClaimsToCheck, LeewaySeconds, HMACAlgorithm) ยท BasicAuth(Validator, Realm) ยท Authorize ยท
RequireRole(Role) / RequireRole(Roles: TArray<string>) (any-of) ยท ActiveRecord(ConnectionDefName)
HTTPFilters โ StaticFiles(Prefix, RootFolder, DefaultDocument = 'index.html') ยท
Compression(Threshold = 1024) ยท ETag ยท IPBlock(...) ยท RateLimit(Max = 60, WindowSeconds = 60) ยท
RequestLog ยท CORSFilter(...) ยท SecurityHeaders ยท Shutdown(...) ยท Analytics(...) ยท Trace(...) ยท
Redirect(...) ยท RangeMedia(URLPath, DocumentRoot) ยท OpenAPI(Engine, Info, '/openapi.json') ยท Swagger(...)
(RateLimitRedis lives in MVCFramework.Filters.Redis.)
7. Web mode โ TemplatePro + HTMX
// EngineConfigU
AEngine.SetViewEngine(TMVCTemplateProViewEngine);
AEngine.UseExceptionHandler('error', 'MyApp');
// config keys: ViewPath, DefaultViewFileExtension, ViewCache
Mark the group .AsWeb (excludes it from OpenAPI) and add a session filter:
var lWeb := ARoot.AsWeb.Use(MemorySession(10));
lWeb.MapGet('/',
function(Ctx: TWebContext): IMVCResponse // MapGet<TWebContext>
begin
ViewData['ispage'] := not Ctx.Request.IsHTMX; // uses MVCFramework.HTMX
ViewData['customers'] := GetCustomers;
Result := RenderView('customers');
end);
Ambient web globals (valid only inside a minimal-API request, otherwise EMVCMinimalAPI):
function ViewData: TMVCViewDataObject;
function RenderView(const AViewName: string): IMVCResponse;
function RenderView(const AViewName: string; const AOnBeforeRender: TMVCSSVBeforeRenderCallback): IMVCResponse;
function RenderViews(const AViewNames: TArray<string>; const AUseCommonHeadersAndFooters: Boolean = True): IMVCResponse;
ViewData is the only ambient helper by design โ session, request, HTMX state must come in as a typed
TWebContext argument. There is no TMVCEngine.WebRoot / WebPrefix (some old sample comments say
otherwise; they are stale).
Session: read/write Ctx.Session['user'], end with Ctx.SessionStop.
The one HTMX idiom you need โ full page vs fragment from the same handler:
ViewData['ispage'] := not Ctx.Request.IsHTMX;
and in baselayout.html, wrap the chrome in {{if ispage}}โฆ{{endif}}.
For TemplatePro syntax and HTMX attributes see the dmvcframework-webapp skill.
Content negotiation: if an rkApi and an rkWeb route share verb+path, the winner is scored on
Accept/Content-Type (web wins on text/html, api on application/json). Ties โ first registered.
8. Validation
- Bound classes: validated automatically if the class carries โฅ1 validator attribute or descends from
TMVCValidatable โ after deserialization, before the handler runs.
- Bound records: validated unconditionally (
ValidateRecord); fields with validator attributes are checked.
- Failure raises
EMVCValidationException โ rendered as RFC-7807 ProblemDetails with 422
(binding errors raise EMVCMinimalAPI โ 400).
type
TCreateCustomerReq = record
[MVCRequired] [MVCMinLength(2)] FirstName: string;
[MVCRequired] LastName: string;
[MVCEmail] Email: string;
end;
9. Worked example
procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
begin
var lApi := ARoot.Prefix('/api');
lApi.MapGet('/customers',
function(Search: TCustomerSearch; Svc: ICustomerService): IMVCResponse // record + DI interface
begin
Result := Ok(Svc.Search(Search.Query, Search.Page)); // TObjectList โ owned & freed
end).WithName('customers.list').Produces<TCustomer>;
lApi.MapGet('/customers/($id:int)',
function(ID: Integer; Svc: ICustomerService): IMVCResponse // primitive โ route segment
var
lCustomer: TCustomer;
begin
lCustomer := Svc.GetByID(ID);
if lCustomer = nil then
Exit(NotFound('Customer not found'));
Result := Ok(lCustomer);
end);
lApi.MapPost('/customers',
function(Req: TCreateCustomerReq; Svc: ICustomerService): IMVCResponse // record โ validated
var
lID: Integer;
begin
lID := Svc.Create(Req.FirstName, Req.LastName, Req.Email);
Result := Created('/api/customers/' + lID.ToString, 'Customer created');
end);
// admin group โ filters applied to every route below
var lAdmin := lApi.Prefix('/admin').Use(Authorize).Use(RequireRole('admin'));
lAdmin.MapDelete('/customers/($id:int)',
function(ID: Integer; Svc: ICustomerService): IMVCResponse
begin
Svc.Delete(ID);
Result := NoContent;
end);
end;
10. Common mistakes
| Mistake | Reality |
|---|
lEngine.MapGet(...) | No Map* on the engine. Go through lEngine.Root / Prefix(...) |
MapGroup('/admin') | Does not exist. It is Prefix('/admin') |
Result := OkResponse(x) | Controller-side name. Minimal API uses standalone Ok(x) |
lGroup.Use(F); on its own line | Groups are records โ the result is discarded. Chain or reassign |
function(Page: Integer) for ?page=2 | Primitives bind to route segments. Use a record + [MVCFromQueryString] |
function(Svc: TCustomerService) | Only interfaces get DI. A class arg means body/query/group data |
| Returning a record | No Ok(record) overload. Return a class or TJsonObject |
Freeing a bound arg / TMVCFormFile.ContentStream | Framework-owned. Do not free |
Adding a middleware after the first MapXxx | It will not run for minimal routes. Register middlewares first |
procedure handler | Handlers are always function ... : IMVCResponse |
11. Key units
| Unit | Purpose |
|---|
MVCFramework.MinimalAPI | Root/Prefix, TMVCRouteGroup<T>, TMVCRouteHandle, TMVCMinimalFunc, TMVCFormFile, ViewData, RenderView |
MVCFramework.Filters | All ready-made endpoint/HTTP filters |
MVCFramework.Filters.Redis | RateLimitRedis |
MVCFramework | TMVCEngine, IMVCResponse, response builders, [MVCFromBody/QueryString/Header/Cookie/ContentField/File] |
MVCFramework.Server.Factory | TMVCServerFactory.CreateIndyDirect / CreateHttpSys / CreateWebBroker |
MVCFramework.HTMX | Request.IsHTMX, Response.HXSet* helpers |
MVCFramework.Container | DefaultMVCServiceContainer, RegisterType, Build |
12. Reference samples
| Folder | Shows |
|---|
samples/minimal_api/ | REST routes, groups, typed Prefix<T> group data, OpenAPI |
samples/minimal_api_webapp/ | .AsWeb, MemorySession, RequireLogin, RenderView |
samples/wizard_showcase/rest/ | Wizard project shape: dpr โ BootConfig โ EngineConfig โ Routes |
samples/wizard_showcase/web/ | Same, web flavour + TemplatePro helpers + HTMX templates |