Skip to content

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's AID parameter.
  • [TArea('read')] -- the user's JWT must include the read area 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:

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

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:

  • TLogRequest and TLogResponse carry ContentLength and ContentOmitted. 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.
  • TLogDiscarded records 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:

Application.MainFormOnTaskbar := False;
Application.ShowMainForm := False;

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

  1. Create the SQL Server database using the scripts in Demos/APIRest/SQL/.
    • Run Employess.SqlServer.sql to create the Companies and Employees tables.
    • Run Log.SqlServer.sql to create the log tables.
  2. Edit API.json with your connection details (server, username, password, database name).
  3. Build and run the project -- the application sits in the system tray and starts the server automatically.
  4. Use the included Postman collection (Trysil APIRest.postman_collection.json) to test the endpoints.

Tip

To add a new entity to the API, define the entity class with Trysil attributes, then register a controller in TAPIHttp.RegisterControllers:

FServer.RegisterController<TAPIReadWriteController<TMyEntity>>('/myentity');
That single line gives you the full set of CRUD endpoints.