REST API¶
A complete walkthrough of the APIRest demo application. This is a VCL system tray application that hosts a REST API server with JWT authentication, generic CRUD controllers, and structured HTTP logging.
Project Structure¶
Demos/APIRest/
API.dpr - Main project
API.json - JSON configuration file
API.MainForm.pas - System tray form
API/
API.Config.pas - JSON configuration loader
API.Context.pas - Per-request context
API.Http.pas - Server setup
Model/
API.Model.Company.pas - Company entity
API.Model.Employee.pas - Employee entity (lazy Company)
Controllers/
API.Controller.pas - Generic CRUD controllers
Authentication/
API.Authentication.pas - Bearer auth handler
API.Authentication.Controller.pas - Login endpoint
API.Authentication.JWT.pas - JWT payload definition
Log/
API.Log.Writer.pas - HTTP log writer
API.Log.Model.pas - Log entities
SQL/
Employess.SqlServer.sql - SQL Server schema script
Employess.SQLite.sql - SQLite schema script
Employess.FirebirdSQL.sql - Firebird schema script
Log.SqlServer.sql - Log tables schema script
Entity Models¶
Company¶
A simple entity with a relation constraint that prevents deleting a company that still has employees.
[TTable('Companies')]
[TSequence('CompaniesID')]
[TRelation('Employees', 'CompanyID', False)]
TAPICompany = class
strict private
[TPrimaryKey]
[TColumn('ID')]
FID: TTPrimaryKey;
[TRequired]
[TMaxLength(100)]
[TColumn('Name')]
FName: String;
[TMaxLength(100)]
[TColumn('Address')]
FAddress: String;
[TMaxLength(100)]
[TColumn('City')]
FCity: String;
[TMaxLength(100)]
[TColumn('Country')]
FCountry: String;
[TVersionColumn]
[TColumn('VersionID')]
FVersionID: TTVersion;
public
property ID: TTPrimaryKey read FID;
property Name: String read FName write FName;
property Address: String read FAddress write FAddress;
property City: String read FCity write FCity;
property Country: String read FCountry write FCountry;
property VersionID: TTVersion read FVersionID;
end;
[TRelation('Employees', 'CompanyID', False)] tells Trysil: "the Employees table references this entity via its CompanyID column, and cascade delete is not allowed." If you attempt to delete a company that has employees, Trysil raises an exception before executing the SQL.
Employee¶
The Employee entity uses TTLazy<TAPICompany> for its Company field -- the related Company entity is loaded from the database only when the Company property is first accessed.
[TTable('Employees')]
[TSequence('EmployeesID')]
TAPIEmployee = class
strict private
[TPrimaryKey]
[TColumn('ID')]
FID: TTPrimaryKey;
[TRequired]
[TMaxLength(100)]
[TColumn('Firstname')]
FFirstname: String;
[TRequired]
[TMaxLength(100)]
[TColumn('Lastname')]
FLastname: String;
[TMaxLength(255)]
[TEmail]
[TColumn('Email')]
FEmail: String;
[TRequired]
[TDisplayName('Company')]
[TColumn('CompanyID')]
FCompany: TTLazy<TAPICompany>;
[TVersionColumn]
[TColumn('VersionID')]
FVersionID: TTVersion;
function GetCompany: TAPICompany;
procedure SetCompany(const AValue: TAPICompany);
public
property ID: TTPrimaryKey read FID;
property Firstname: String read FFirstname write FFirstname;
property Lastname: String read FLastname write FLastname;
property Email: String read FEmail write FEmail;
property Company: TAPICompany read GetCompany write SetCompany;
property VersionID: TTVersion read FVersionID;
end;
The getter and setter delegate to FCompany.Entity:
function TAPIEmployee.GetCompany: TAPICompany;
begin
result := FCompany.Entity;
end;
procedure TAPIEmployee.SetCompany(const AValue: TAPICompany);
begin
FCompany.Entity := AValue;
end;
The [TDisplayName('Company')] attribute provides a human-readable name for the field, used by metadata endpoints and validation error messages.
Configuration¶
The application reads its configuration from a JSON file alongside the executable (API.json):
{
"server": {
"baseUri": "",
"port": 4450
},
"cors": {
"allowHeaders": "",
"allowOrigin": "*"
},
"authentication": {
"secret": ""
},
"database": {
"connectionName": "",
"server": "",
"username": "",
"password": "",
"databaseName": ""
}
}
TAPIConfig is a lazy singleton that loads this file on first access:
TAPIConfig = class
public
property Server: TAPIServerConfig read FServer;
property Cors: TAPICorsConfig read FCors;
property Database: TAPIDatabaseConfig read FDatabase;
class property Instance: TAPIConfig read GetInstance;
end;
Per-Request Context¶
Each HTTP request gets its own TAPIContext, which creates a database connection (from the pool), a TTHttpContext for ORM + JSON operations, and a JWT payload container.
TAPIContext = class
strict private
FConnection: TTConnection;
FContext: TTHttpContext;
FPayload: TAPIJWTPayload;
public
constructor Create;
destructor Destroy; override;
property Context: TTHttpContext read FContext;
property Payload: TAPIJWTPayload read FPayload;
end;
constructor TAPIContext.Create;
begin
inherited Create;
FConnection := TTSqlServerConnection.Create(
TAPIConfig.Instance.Database.ConnectionName);
FContext := TTHttpContext.Create(FConnection);
FPayload := TAPIJWTPayload.Create;
end;
Note
TTHttpContext extends TTJSonContext (which extends TTContext), so it provides the full ORM API plus JSON serialization/deserialization in a single object.
Generic CRUD Controllers¶
The demo defines reusable generic controllers that work with any entity type. This eliminates boilerplate -- you register the same controller class for each entity, and the generic type parameter determines which table it operates on. The controllers declare the routes and the areas; the work is done by TTHttpEntityReader<T> and TTHttpEntityWriter<T> from Trysil.Http.Entity, to which they delegate. See Generic CRUD Controllers.
Base Controller¶
All controllers extend a common base that extracts TTHttpContext from the per-request TAPIContext:
TAPIController = class(TTHttpController<TAPIContext>)
strict protected
property Context: TTHttpContext read GetContext;
end;
Read-Only Controller¶
Provides GET (by ID), GET (all), POST (filtered select), and GET (metadata) endpoints. It creates a TTHttpEntityReader<T> on the request's TTHttpContext and frees it with itself:
TAPIReadOnlyController<T: class> = class(TAPIController)
strict private
FReader: TTHttpEntityReader<T>;
public
constructor Create(
const AContext: TAPIContext;
const ARequest: TTHttpRequest;
const AResponse: TTHttpResponse); override;
destructor Destroy; override;
[TGet('/?')]
[TArea('read')]
procedure Get(const AID: TTPrimaryKey);
[TGet]
[TArea('read')]
procedure SelectAll;
[TPost('/select')]
[TArea('read')]
procedure Select;
[TGet('/find/?')]
[TArea('read')]
procedure Find(const AID: TTPrimaryKey);
[TGet('/metadata')]
[TArea('read')]
procedure Metadata;
end;
[TGet('/?')]-- the?is a route parameter placeholder that maps to the method'sAIDparameter.[TArea('read')]-- the user's JWT must include thereadarea to access these endpoints.
Each endpoint is one line:
constructor TAPIReadOnlyController<T>.Create(
const AContext: TAPIContext;
const ARequest: TTHttpRequest;
const AResponse: TTHttpResponse);
begin
inherited Create(AContext, ARequest, AResponse);
FReader := TTHttpEntityReader<T>.Create(Context);
end;
procedure TAPIReadOnlyController<T>.Select;
begin
FResponse.Content := FReader.Select(FRequest.JSonContent);
end;
The Select endpoint accepts a filter payload in the request body, which the reader parses with TTHttpFilter<T>.
The payload is {"where": [{"columnName", "condition", "value"}], "orderBy": [...], "start", "limit"}.
The column name is checked against the table metadata and the operator against a closed list. columnName may be the column name or the JSON name of the member, the one the client sees in every response; the column name is tried first, and if the column it finds is hidden from the responses or not filterable, the filter is refused: the name is not passed on to another member whose JSON name is spelled the same. And when the column it finds is visible and filterable, the filter reaches that column even if the payload published the same name for another member: keep the JSON names of the members apart from the column names. A refusal repeats the name the client sent. The name that reaches the SQL text is the canonical one from the metadata, not the string the client sent. The value never reaches the SQL text: each condition emits a :p0, :p1 placeholder and the value is bound as a typed parameter, converted from the column's TFieldType. A date or a timestamp is read as ISO 8601: with a time zone it is moved to the server's local time, without one it is the server's local time as written. Beyond injection, this keeps the plan cache from filling with single-use plans -- one distinct SQL text per distinct value would evict the plans that matter, degrading the whole database and not just the endpoint.
Anything that does not add up is a 400 at parse time, not a failed query: unknown column, operator outside the closed list, a value that does not match the column type, a non-object item inside where or inside orderBy, or LIKE on a non-string column.
A field of the payload that carries the wrong kind of value is a 400 as well: {"where": {}} where an array is expected, {"start": {}} where a number is, {"condition": []} where a string is. The filter is refused whole rather than applied in part, because the part that gets dropped is a restriction: a where that is not an array used to leave no filter at all, and the endpoint answered with the table.
Ceilings¶
The reader's two-argument constructor takes a TTHttpFilterParameters and hands it to TTHttpFilter<T>; the one-argument one applies TTHttpFilterParameters.Defaults -- MaxLimit 1000, MaxWhereConditions 32, MaxOrderByColumns 8, IncludeDeleted False.
FReader := TTHttpEntityReader<T>.Create(
Context,
TTHttpFilterParameters.Create(200, 16, 4, UserMaySeeDeleted));
limit is clamped rather than trusted: absent, zero or negative means MaxLimit, and a larger value is capped to it. More where conditions or orderBy columns than the maximum is a 400. A negative ceiling means unlimited, for an endpoint that genuinely needs it, and a zero ceiling means the default, so a TTHttpFilterParameters left at its zero state - Default(TTHttpFilterParameters), or a field of a class, which the RTL zeroes - does not silently remove the cap.
A local variable is not the zero state
The record has no managed fields, so nothing zeroes it on the stack: a local you declare and never assign holds whatever was there. A garbage MaxLimit that happens to be negative is unlimited, and a garbage IncludeDeleted byte reads as True, which returns the soft-deleted rows. Initialise it with Default(TTHttpFilterParameters) or one of the constructors - never rely on the declaration alone.
This governs the filter built from the payload, and the reader's SelectAll builds it too, with no body: it stops at MaxLimit like Select. An endpoint that builds its own TTFilter is not affected: TTFilter.Empty carries no pagination clause and returns every row.
Behaviour change
LIKE on a non-string column used to go through, relying on the engine's implicit conversion. It now returns 400. A client filtering that way needs fixing.
Behaviour change
A payload field of the wrong kind used to depend on the Delphi version: a 500 up to Delphi 11, and silently the default value from Delphi 12 on. It is now a 400 everywhere.
Behaviour change
includeDeleted is no longer read from the payload. Letting the client turn off the soft-delete filter made row visibility a client decision. It is now TTHttpFilterParameters.IncludeDeleted, set server-side after your own authorization check.
Behaviour change
An omitted limit used to mean no pagination at all -- the whole table. It now means MaxLimit. An endpoint that really returns everything must say so with a negative MaxLimit.
Behaviour change
A positive start with no resolvable limit is a 400. It can only happen on an endpoint with a negative MaxLimit, since otherwise the ceiling supplies the missing limit: there the request used to return the whole table from the first row, ignoring the start the client asked for. A start of 0 is unaffected.
Read-Write Controller¶
Extends the read-only controller with Insert, Update, Delete, and CreateNew, delegated to a TTHttpEntityWriter<T>:
TAPIReadWriteController<T: class> = class(TAPIReadOnlyController<T>)
strict private
FWriter: TTHttpEntityWriter<T>;
public
constructor Create(
const AContext: TAPIContext;
const ARequest: TTHttpRequest;
const AResponse: TTHttpResponse); override;
destructor Destroy; override;
[TPost]
[TArea('write')]
procedure Insert;
[TPut]
[TArea('write')]
procedure Update;
[TDelete('/?/?')]
[TArea('write')]
procedure Delete(const AID: TTPrimaryKey; const AVersionID: TTVersion);
[TGet('/createnew')]
[TArea('write')]
procedure CreateNew;
end;
The Delete endpoint takes both the ID and the version ID as route parameters (/?/?). The version ID is required for optimistic locking -- if the version does not match the current database value, the delete fails with a 409. An ID that is not there is a 404.
procedure TAPIReadWriteController<T>.Insert;
begin
FResponse.Content := FWriter.Insert(FRequest.JSonContent);
end;
procedure TAPIReadWriteController<T>.Update;
begin
FResponse.Content := FWriter.Update(FRequest.JSonContent);
end;
TTHttpEntityWriter<T>.Insert deserializes the entity, takes the ID from the sequence when the body has none, and inserts it. Update deserializes, updates, and reloads the row before serializing the response: the body no longer carries the change tracking columns, which are the framework's to write, so EntityFromJSonObject skips them, and without the reload the response would echo an entity whose createdAt and createdBy are blank.
An update endpoint that deserializes onto the loaded entity instead of a fresh one is the other shape, and it does not need the reload for the tracking columns -- see Deserialization.
Note what this endpoint accepts. Update addresses the row through the id in the body, and the body is also what fills every other mapped column: a foreign key behind a TTLazy<T>, a [TDetailColumn] collection, the version. That is what makes the generic controller generic, and it is also why the entity is where you say what a client may not write. A column that decides who may see the row - a tenant, an owner - is repointed by a PUT like any other, and [TArea('write')] does not help, because the caller does hold the area: what it must not hold is that particular row. Close those fields with [TJSonIgnoreDeserialize] and scope the read the endpoint does before the write. Restricting what the body may write has the details, and the two columns not to close.
Server Setup¶
TAPIHttp wires everything together: connection registration, CORS, authentication, logging, and controller registration.
constructor TAPIHttp.Create;
begin
inherited Create;
FServer := TTHttpServer<TAPIContext>.Create;
end;
procedure TAPIHttp.AfterConstruction;
begin
inherited AfterConstruction;
FServer.BaseUri := TAPIConfig.Instance.Server.BaseUri;
FServer.Port := TAPIConfig.Instance.Server.Port;
FServer.CorsConfig.AllowHeaders := TAPIConfig.Instance.Cors.AllowHeaders;
FServer.CorsConfig.AllowOrigin := TAPIConfig.Instance.Cors.AllowOrigin;
TTFireDACConnectionPool.Instance.Config.Enabled := True;
TTSqlServerConnection.RegisterConnection(
TAPIConfig.Instance.Database.ConnectionName,
TAPIConfig.Instance.Database.Server,
TAPIConfig.Instance.Database.Username,
TAPIConfig.Instance.Database.Password,
TAPIConfig.Instance.Database.DatabaseName);
FServer.RegisterLogWriter<TAPILogWriter>(
TTHttpLogParameters.Create(2, 10000, 65536));
FServer.RegisterAuthentication<TAPIAuthentication>();
// Register controllers for each entity type
FServer.RegisterController<TAPILogonController>();
FServer.RegisterController<TAPIReadWriteController<TAPICompany>>('/company');
FServer.RegisterController<TAPIReadWriteController<TAPIEmployee>>('/employee');
end;
Adding a new entity to the API requires just one line: register a new TAPIReadWriteController<TYourEntity> with its base path.
JWT Authentication¶
Login Endpoint¶
The logon controller is marked with [TAuthorizationType(TTHttpAuthorizationType.None)] so it can be accessed without a token:
[TUri('/logon')]
[TAuthorizationType(TTHttpAuthorizationType.None)]
TAPILogonController = class(TAPIController)
public
[TPost]
procedure Logon;
end;
procedure TAPILogonController.Logon;
begin
FUsername := FRequest.JSonContent.GetValue<String>('username', '');
FPassword := FRequest.JSonContent.GetValue<String>('password', '');
CheckCredentials;
ResponseToken;
end;
CheckCredentials validates the username/password and populates the user's areas (permissions). ResponseToken generates a JWT containing the username, areas, and a 30-minute expiry, then returns it as JSON:
Bearer Authentication Handler¶
Protected endpoints require a Bearer token in the Authorization header. TAPIAuthentication extends TTHttpAuthenticationBearer and validates the JWT payload:
TAPIAuthentication = class(TTHttpAuthenticationBearer<
TAPIContext, TAPIJWTPayload>)
strict protected
function CreatePayload: TAPIJWTPayload; override;
function IsValid(const APayload: TAPIJWTPayload): Boolean; override;
end;
function TAPIAuthentication.IsValid(const APayload: TAPIJWTPayload): Boolean;
begin
result := APayload.IsValid;
if result then
begin
Context.Payload.Assign(APayload);
FRequest.User.Username := APayload.Username;
for LArea in APayload.Areas do
FRequest.User.Areas.Add(LArea);
end;
end;
The [TArea('read')] and [TArea('write')] attributes on controller methods are checked against the areas in the JWT payload. A user with only the read area cannot call Insert, Update, or Delete.
Structured HTTP Logging¶
The demo logs every HTTP request, response, and action to database tables via TAPILogWriter:
TAPILogWriter = class(TTHttpLogAbstractWriter)
public
procedure WriteAction(const AAction: TTHttpLogAction); override;
procedure WriteRequest(const ARequest: TTHttpLogRequest); override;
procedure WriteResponse(const AResponse: TTHttpLogResponse); override;
procedure WriteDiscarded(
const ADiscarded: TTHttpLogDiscarded); override;
end;
procedure TAPILogWriter.WriteRequest(const ARequest: TTHttpLogRequest);
var
LLogRequest: TLogRequest;
begin
LLogRequest := FContext.Context.CreateEntity<TLogRequest>();
try
LLogRequest.SetValues(ARequest);
FContext.Context.Insert<TLogRequest>(LLogRequest);
finally
FContext.Context.FreeEntity<TLogRequest>(LLogRequest);
end;
end;
The log entities (TLogAction, TLogRequest, TLogResponse, TLogDiscarded) are standard Trysil entities mapped to tables in the log schema. The SQL scripts in SQL/Log.SqlServer.sql create these tables.
Bounded queue and capped bodies¶
The writer is registered with a TTHttpLogParameters record: two log threads, a queue capped at 10 000 entries per thread, and request/response bodies captured up to 64 KB.
Both caps are visible in the stored rows rather than silent:
TLogRequestandTLogResponsecarryContentLengthandContentOmitted. When a body is over the cap it is not captured at all -- and it is never parsed nor re-serialized either, so the cost is avoided rather than merely discarded -- but the row still records how big it was.TLogDiscardedrecords entries the queue had to refuse, aggregated per host:
procedure TAPILogWriter.WriteDiscarded(
const ADiscarded: TTHttpLogDiscarded);
var
LLogDiscarded: TLogDiscarded;
begin
LLogDiscarded := FContext.Context.CreateEntity<TLogDiscarded>();
try
LLogDiscarded.SetValues(ADiscarded);
FContext.Context.Insert<TLogDiscarded>(LLogDiscarded);
finally
FContext.Context.FreeEntity<TLogDiscarded>(LLogDiscarded);
end;
end;
Overriding WriteDiscarded is optional: it is virtual but not abstract, and its default implementation already reports discards through WriteAction. The demo overrides it to give them their own table, which is also what a multi-tenant writer would do to route each host's losses to the right database.
WriteError is the other override, and the demo gives it its own log.Errors table alongside the other three. It matters more than it looks: since the 500 response carries only a constant and the task id, this is the only place the exception detail exists. The default implementation forwards the whole payload to WriteAction, whose text has no bounded length - an Action column sized for short messages rejects the row, the log thread swallows the database error to stay alive, and the trace disappears precisely when someone is looking for it. Either override WriteError or size the column for it.
System Tray Application¶
The main form runs as a system tray application. It creates the HTTP server on startup and provides Start/Stop/Terminate actions via the tray icon's popup menu:
constructor TAPIMainForm.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FHttp := TAPIHttp.Create;
end;
procedure TAPIMainForm.AfterConstruction;
begin
inherited AfterConstruction;
FHttp.Start;
end;
The .dpr file hides the main form:
API Endpoints¶
Once running, the server exposes the following endpoints:
| Method | Endpoint | Description |
|---|---|---|
POST |
/logon |
Authenticate and receive a JWT |
GET |
/company |
List all companies |
GET |
/company/{id} |
Get company by ID |
GET |
/company/find/{id} |
Find company by ID (shallow) |
POST |
/company/select |
Filtered query |
GET |
/company/metadata |
Entity metadata |
POST |
/company |
Insert company |
PUT |
/company |
Update company |
DELETE |
/company/{id}/{versionId} |
Delete company |
GET |
/company/createnew |
Get empty entity template |
The same endpoints are available for /employee.
Running the Demo¶
- Create the SQL Server database using the scripts in
Demos/APIRest/SQL/.- Run
Employess.SqlServer.sqlto create theCompaniesandEmployeestables. - Run
Log.SqlServer.sqlto create the log tables.
- Run
- Edit
API.jsonwith your connection details (server, username, password, database name). - Build and run the project -- the application sits in the system tray and starts the server automatically.
- Use the included Postman collection (
Trysil APIRest.postman_collection.json) to test the endpoints.