dmvcframework-minimal-api
Skill23 starsChanged 7 days agoApache-2.0View on GitHub
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", "filter bundle", "TMVCRouteGroup", "AsWeb", "lambda routes", "no controller", "Prefix", "group.Use", "TMVCFormFile", "MVCFromQueryString", "wizard Minimal API preset". Covers both Minimal REST APIs and Minimal web apps (TemplatePro/HTMX via .AsWeb).
- Reads credentials
1 other project uses a version of this.
Adopted by
Most recently taken up by
- danieleteti/delphimvcframeworkword for word, first seen Nov 2, 2026
Other versions1 project uses a version of this.
Word for word · 1
- danieleteti/delphimvcframework★ 1.4k100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository15 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- delphi-buildSkill
- delphi-code-smellsSkill
- delphi-firedacSkill
- delphi-securitySkill
- delphiSkill
- delphi-testingSkill
- dmvcframework-jsonrpcSkill
- dmvcframework-securitySkill
- dmvcframeworkSkill
- dmvcframework-testingSkill
- dmvcframework-uiSkill
- dmvcframework-webappSkill
- exewatch-delphiSkill
- htmx-skillSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 2 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 17
Never invent an identifier or answer from memory.
- Distinctive: line 22
If you still cannot verify it, say so.
- Very specific: line 39
Use the controller-based API (skill dmvcframework) when you want class-level inheritance or OnBeforeAction hooks.
- Very specific: line 70
Read RoutesU.pas and EngineConfigU.pas before writing anything, and follow their conventions.
- Distinctive: line 75
Step 2 — if there is no wizard project, stop: Do not scaffold one. Tell the user:
- Very specific: line 88
The host may be WebBroker (ISAPI, Apache) — that is fine: Keep it, do not migrate it, do not suggest migrating it.
- Very specific: line 491
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, …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 or OnBeforeAction
hooks. The filters (§6) work on both.
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 (one example: GET /api/v1/hello)
ServicesU.pas DI registrations: RegisterServices (on by default)
bin/.env port and settingsRead RoutesU.pas and EngineConfigU.pas before writing anything, and follow their conventions.
On Delphi 12 and later the user can add a route group or a service from the IDE: right-click the project in
the Project Manager → DMVCFramework → New Minimal API Route Group... / New Service...; when they
prefer that, tell them which item to use instead of writing the files yourself.
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
cdinto 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: a classic middleware (deprecated in 3.5 — write a filter, §6) must be added 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. The engine-wide filters and
bundles (AEngine.Use(...)) go in ConfigureEngine, which runs before ConfigureRoutes.
2. Route registration DSL
Everything hangs off a group. There are no Map* methods on TMVCEngine.
// TMVCEngine helpers (MVCFramework.MinimalAPI)
function Root: TMVCRouteGroup<TObject>; // = Prefix('')
function Prefix(const APrefix: string): TMVCRouteGroup<TObject>; overload;
function Prefix<T: class>(const APrefix: string; const AData: T;
const AOwnership: TMVCGroupDataOwnership = gdoEngine): TMVCRouteGroup<T>; overload;
// TMVCEngine (MVCFramework): one verb, the argument's type picks the overload
function Use(const AFilter: TMVCHTTPFilter): TMVCEngine; overload;
function Use(const AFilter: TMVCEndpointFilter): TMVCEngine; overload; // every route, controllers too
function Use(const ABundle: IMVCFilterBundle): TMVCEngine; overload;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>; overload;
function Use(const ABundle: IMVCFilterBundle): TMVCRouteGroup<T>; overload; // endpoint part here, HTTP part on the engine
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>.
Always write the type arguments — lWeb.MapGet<TWebContext>('/', function(Ctx: TWebContext): IMVCResponse ...).
Delphi does not infer them from the anonymous method: without them the call fails with E2250 There is no
overloaded version of 'MapGet'. The plain MapGet(APath, AHandler) takes only a handler with no arguments.
Value-semantics trap:
Use/Prefix/AsWebreturn a new group.lGroup.Use(AuthorizeEndpointFilter());on its own line is a no-op. Chain it or reassign:lGroup := lGroup.Use(AuthorizeEndpointFilter());With a bundle it is worse than a no-op:lGroup.Use(JWTAuthenticationBundle(...));alone registers the login on the engine and loses the check of the group.A filter without arguments needs
():Use(AuthorizeEndpointFilter()),AEngine.Use(ETagHTTPFilter()),AEngine.Use(SecurityHeadersHTTPFilter()). Without them Delphi passes the function itself, not the filter it returns: E2250 There is no overloaded version of 'Use'.No HTTP filter on a group.
lGroup.Use(CORSHTTPFilter('*'))does not compile (E2250): an HTTP filter runs before routing, so it cannot run for one group. Register it on the engine. A route (TMVCRouteHandle.Use) takes an endpoint filter only.
Path syntax = standard DMVC: ($id), with optional constraint ($id:int).
Constraints: int, int64, float, bool, guid, date, sqids. A failed constraint means the route
simply does not match (→ 404 or the next route). An unknown constraint name raises EMVCException when the
route is registered, at startup.
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(oavHidden) // hide from the spec
.Use(RequireRoleEndpointFilter('admin')); // route-scoped filter, runs after the group's3. 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=2will not land in a bareIntegerarg. 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 otherTArray<System.*>raises.[MVCFromFile('field')]only renames the form field for aTMVCFormFile/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 forTObjectList<T>, entities,TJsonObject.Ok(string)wraps the string as{"message": "..."}.ProblemDetails(...)is the RFC 7807 object itself, sent asapplication/problem+json(on 3.5.0-rc7 and earlier it came out wrapped in{"data": ...}asapplication/json).- Records cannot be returned. There is no
Ok(record)overload — build a class or aTJsonObject. - The result is mutable:
Result := Ok(lData); Result.StatusCode := 201; - HTML:
RenderView(§7) for a template;Html(const AHtml: string)for a small fragment with no template (an HTMX swap target) — it sends the string as is, nothing escaped, so encode any untrusted value in it.
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), one route (handle.Use), or every route — controller actions too (lEngine.Use) | the engine only (lEngine.Use) |
| 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; the engine-wide ones outside the group's; first registered = outermost | all HTTPFilters run before any EndpointFilter |
Skipping ANext() short-circuits the chain. Nested Prefix inherits the parent's filters.
The base types are declared in MVCFramework (the names stay valid from MVCFramework.MinimalAPI).
Filter bundles (IMVCFilterBundle, MVCFramework, re-exported by MVCFramework.MinimalAPI): filters that go
together — an HTTP part and an endpoint part, each in its order — registered by one Use. On a group,
lGroup.Use(B) puts the endpoint part on the group and the HTTP part on the engine; on the engine, lEngine.Use(B)
puts both engine-wide. The same bundle instance on two sibling groups is allowed (HTTP part once); on the engine and
on a group, on a group and a group nested in it, or twice on the engine, it raises EMVCConfigException at the
second Use. A bundle with no endpoint part (FileServerBundle) on a group raises too: use it on the engine. Build one with
TMVCFilterBundle.Create([HTTP filters], [endpoint filters]); an endpoint filter held in a variable inside [...]
is called instead of passed (E2035) — write [TMVCEndpointFilter(lFilter)].
Custom endpoint filter — the whole shape:
function RequireLoginEndpointFilter(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)
A filter's name ends in EndpointFilter (engine, group or route) or HTTPFilter (engine only); a function that
builds a bundle ends in Bundle (engine or group). All of them are registered with Use.
EndpointFilters — AuthorizeEndpointFilter · RequireRoleEndpointFilter(Role) / RequireRoleEndpointFilter(Roles: TArray<string>) (any-of) ·
BasicAuthEndpointFilter(Validator, Realm) / BasicAuthEndpointFilter(Handler, Realm) (pass the realm with a validator,
and write TAuthentication.Create as IMVCAuthenticationHandler for a handler: otherwise the overloads do not resolve,
E2250) · CSRFEndpointFilter(...) ·
MemorySessionEndpointFilter(TimeoutMinutes = 0; Flags = [scfHttpOnly]) · FileSessionEndpointFilter(TimeoutMinutes, Folder, Flags) ·
SessionEndpointFilter(Factory) · CORSEndpointFilter(...) (matched routes only: it does not answer a preflight — use CORSHTTPFilter)
HTTPFilters — StaticFilesHTTPFilter(Prefix, RootFolder, Mode = sfmFiles, DefaultDocument = 'index.html') (sfmSPA
for a single-page app) / StaticFilesHTTPFilter(TMVCStaticFilesOptions) · CompressionHTTPFilter(Threshold = 1024) · ETagHTTPFilter ·
CORSHTTPFilter(Origins = '*', AllowsCredentials = False, ...) · RateLimitHTTPFilter(Max = 60, WindowSeconds = 60) /
RateLimitHTTPFilter(TMVCRateLimitOptions) · IPBlockHTTPFilter(...) · RequestLogHTTPFilter · SecurityHeadersHTTPFilter · ShutdownHTTPFilter(...) ·
AnalyticsHTTPFilter(...) · TraceHTTPFilter(...) · RedirectHTTPFilter(...) · RangeMediaHTTPFilter(URLPath, DocumentRoot) ·
MemorySessionHTTPFilter / FileSessionHTTPFilter / SessionHTTPFilter · request guards BearerAuthHTTPFilter,
AllowedOriginHTTPFilter, AllowedHostHTTPFilter, AllowedMethodsHTTPFilter · CSRFHTTPFilter
Bundles — FileServerBundle(URLPrefix, DocumentRoot, Mode = fsmFiles) / FileServerBundle(TMVCFileServerOptions):
the files of StaticFilesHTTPFilter (MIME type, Last-Modified/304, default document, path traversal refused) plus
206 Partial Content for a GET or HEAD with a Range header (a HEAD gets the headers only). fsmSPA: a GET/HEAD with no route gets index.html, so an
API keeps its routes on the same prefix. The options (TMVCFileServerOptions.Default) add DefaultDocument,
Charset, Flags ([fsfRanges] by default; fsfCaseSensitivePrefix), MediaTypesCustomizer, Rules. An HTTP part
only: register it on the engine (on a group it raises). · MVCFramework.Filters.Authentication —
JWTAuthenticationBundle(Handler, ClaimsSetup, Secret, LoginURL = '/login') / JWTAuthenticationBundle(TMVCJWTAuthenticationOptions),
JWTCookieAuthenticationBundle(Handler, ClaimsSetup, Secret, Cookie, LoginURL = '/login', LogoutURL = '/logout') /
JWTCookieAuthenticationBundle(TMVCJWTCookieAuthenticationOptions), BasicAuthenticationBundle(Handler, Realm),
CustomAuthenticationBundle(Handler, LoginURL) (the options, the blacklist and the refresh token: dmvcframework
skill, Filters) · MVCFramework.Filters.OIDC — OIDCAuthenticationBundle(TMVCOIDCOptions)
Companion units (the core unit pulls in no FireDAC, SwagDoc or Redis): MVCFramework.Filters.ActiveRecord —
ActiveRecordEndpointFilter(ConnectionDefName), DatabaseSessionEndpointFilter, ActiveRecordHTTPFilter, DatabaseSessionHTTPFilter ·
MVCFramework.Filters.OpenAPI — OpenAPIHTTPFilter(Engine, Info, '/openapi.json') /
OpenAPIHTTPFilter(Engine, Info, [Sources], '/openapi.json') (your IMVCOpenAPISources, after the routes and the
controllers) · MVCFramework.Filters.Swagger — SwaggerHTTPFilter(Engine, Info, URL, JWTDescription,
Authentications = [], ...): with JWTAuthenticationBundle pass [sauBearer] (TMVCSwaggerAuthentications), because
the filter finds a JWT on its own only in the deprecated middleware · MVCFramework.Filters.Redis — RateLimitRedisHTTPFilter ·
MVCFramework.JWT.RefreshToken.Filters — RefreshTokenHTTPFilter (the refresh endpoint alone, for a login of your own).
A login route of your own (it issues the tokens itself): TMVCJWTAuthenticationOptions with LoginURL := ''
gives a bundle with the check only (plus the blacklist and the refresh URL when set); ClaimsSetup stays nil.
The login route must be outside the check: a Minimal API route is always checked (OnRequest is not asked),
so put this bundle on the group it protects — lApi.Prefix('/admin').Use(JWTAuthenticationBundle(lJWTOptions)) —
and map the login on a group without it. Engine-wide, the check would cover the login route too. A refresh store,
when used, is one global instance for the process (see dmvcframework, Filters).
Authentication on a group
An authentication bundle on the group: the check (endpoint part) covers the group's routes, the login needs no
route and goes on the engine by itself (as the wizard generates it, in Routes.RootU):
// var lJWTOptions: TMVCJWTAuthenticationOptions;
// POST /api/v1/admin/login (jwtusername / jwtpassword) answers {"token": "..."};
// every route on lAdmin needs the token
lJWTOptions := TMVCJWTAuthenticationOptions.Default;
lJWTOptions.Handler := TAuthentication.Create;
lJWTOptions.ClaimsSetup := procedure(const JWT: TJWT)
begin
JWT.Claims.Issuer := dotEnv.Env('JWT_ISSUER', 'MyApp');
JWT.Claims.ExpirationTime := Now + OneHour;
JWT.Claims.NotBefore := Now - OneMinute * 5;
JWT.Claims.IssuedAt := Now;
end;
lJWTOptions.Secret := dotEnv.Env('JWT_SECRET'); // no fallback for the key
lJWTOptions.LoginURL := '/api/v1/admin/login';
lJWTOptions.ClaimsToCheck := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore, TJWTCheckableClaim.IssuedAt];
lAdmin := lApi.Prefix('/admin')
.Use(JWTAuthenticationBundle(lJWTOptions))
.Use(RequireRoleEndpointFilter('admin'));The short form, JWTAuthenticationBundle(TAuthentication.Create, lClaimsSetup, dotEnv.Env('JWT_SECRET'),
'/api/v1/admin/login'), checks the expiration only. The same bundle, kept in a variable (lAuth: IMVCFilterBundle),
can go on two groups: its login is registered once.
On a route, the handler's OnRequest is not asked (authentication is always required) and
OnAuthorization gets '' as controller and 'VERB pattern' as action. The login answers any verb:
CORSHTTPFilter or RateLimitHTTPFilter that must cover it are HTTP filters registered on the engine before it.
7. Web mode — TemplatePro + HTMX
// EngineConfigU
AEngine.SetViewEngine(TMVCTemplateProViewEngine);
AEngine.UseExceptionHandler('error', 'MyApp');
// config keys: ViewPath, DefaultViewFileExtension, ViewCacheMark the group .AsWeb (excludes it from OpenAPI) and add a session filter:
var lWeb := ARoot.AsWeb.Use(MemorySessionEndpointFilter(30, SessionCookieFlags(dotEnv.Env('SESSION_COOKIE_SECURE', True)))); // as the wizard
lWeb.MapGet<TWebContext>('/customers',
function(Ctx: TWebContext): IMVCResponse
var
lCustomers: TObjectList<TCustomer>;
begin
lCustomers := GetCustomers;
try
ViewData['customers'] := lCustomers;
// HTMX asks for the table only; a normal request gets the whole page
if Ctx.Request.IsHTMX and not Ctx.Request.HXIsBoosted
and not Ctx.Request.HXIsHistoryRestoreRequest then // uses MVCFramework.HTMX
Result := RenderView('customers/table')
else
Result := RenderView('customers/index');
finally
lCustomers.Free; // ViewData owns nothing
end;
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.
Page vs fragment: the handler above; the template pair, TemplatePro and HTMX are in dmvcframework-webapp.
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. Error keys are field names; noOnValidate, no nesting, and cross-field validators (MVCCompareField, …) get no owner object and do not check — use a class for rules that span two fields. - Failure raises
EMVCValidationException→ rendered as RFC-7807 ProblemDetails with 422 (binding errors → 400:EMVCMinimalAPIfor a route/query value,EMVCExceptionfor malformed JSON). The body has"detail": "Validation failed for fields: Email, FirstName"plus an"errors"object with the per-field messages ({"Email": "...", ...}).errorsarrived after 3.5.0-rc7: on rc7 and earlier onlydetailis there. - Validators read properties of a class (fields of a record); on a
NullableXxxproperty onlyMVCRequiredchecks anything. - HTML form posts (
Ctx.Request.ContentFields) are not validated for you: copy the fields into an object, validate, re-render withformModel/formErrorsand 422.
The full attribute list, the error-map response, the form re-render pattern and custom validators are in the
dmvcframework skill, reference/validation.md.
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<TCustomerSearch, ICustomerService>('/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<Integer, ICustomerService>('/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<TCreateCustomerReq, ICustomerService>('/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. AuthorizeEndpointFilter needs a user: an authentication
// bundle before it (.Use(JWTAuthenticationBundle(...))), or BearerAuthHTTPFilter in EngineConfigU (the wizard's non-JWT admin group)
var lAdmin := lApi.Prefix('/admin').Use(AuthorizeEndpointFilter()).Use(RequireRoleEndpointFilter('admin'));
lAdmin.MapDelete<Integer, ICustomerService>('/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 |
Object loaded, then Update/Insert/anything that can raise, then Ok(x) | Leaks on the exception: Ok owns it only once called. Wrap the stretch in try ... except x.Free; raise; end |
Adding a middleware after the first MapXxx | It will not run for minimal routes. Register middlewares first — better, write the filter: every classic middleware is deprecated in 3.5 |
JWT(handler, claimsSetup, secret, '/login', ...) | The rc8 name and signature, gone after rc8. Write .Use(JWTAuthenticationBundle(handler, claimsSetup, secret, '/login')): the check on the group, the login on the engine, in one call |
lEngine.UseHTTPFilter(...) | The rc8 name. After rc8 it is lEngine.Use(...), for HTTP filters, endpoint filters and bundles alike |
CORSHTTPFilter(...) on a group / CORS(...), CORSFilter(...) | CORSHTTPFilter is the HTTP filter (answers the preflight, lEngine.Use); on a group it does not compile. The endpoint one is CORSEndpointFilter. In rc8 CORSFilter was the HTTP filter and CORS the endpoint one |
Use(AuthorizeEndpointFilter) | E2250: a filter function without arguments needs () |
| The same bundle on the engine and on a group | EMVCConfigException at startup: the check would run twice on the group. Put it in one place |
MemorySession(30, True, lSecure) | The rc8 form. The cookie flags are now a set: MemorySessionEndpointFilter(30, [scfHttpOnly, scfSecure]) or SessionCookieFlags(lSecure) |
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 | The ready-made endpoint/HTTP filters, FileServerBundle, TMVCAuthCookieOptions, SessionCookieFlags |
MVCFramework.Filters.Authentication | JWTAuthenticationBundle, JWTCookieAuthenticationBundle, BasicAuthenticationBundle, CustomAuthenticationBundle and their options |
MVCFramework.Filters.ActiveRecord · .OpenAPI · .Swagger · .OIDC · .Redis | The filters that need FireDAC, the OpenAPI emitter, SwagDoc, the REST client, Redis (list in §6) |
MVCFramework.JWT | TJWT, TJWTClaimsSetup, TJWTCheckableClaim (for JWTAuthenticationBundle) |
MVCFramework | TMVCEngine and its Use, IMVCFilterBundle, TMVCFilterBundle, 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, MemorySessionEndpointFilter, RequireLoginEndpointFilter, RenderView |
samples/wizard_showcase/rest/ | Wizard project shape: dpr → BootConfig → EngineConfig → Routes |
samples/wizard_showcase/web/ | Same, web flavour + TemplatePro helpers + HTMX templates |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

