Seed web-services (API v2) knowledge domain (#45)

* Seed web-services (API v2) knowledge domain

Add eight web-services API page knowledge articles (each with .good.al/.bad.al samples), a new al-web-services-review leaf skill, and wire it into al-code-review and the README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Trim web-services domain to 6 non-duplicative articles

Drop API entity-naming/camelCase and DelayedInsert articles (owned by the style domain). Reframe the committed-data and API-versioning articles to stay strictly within the endpoint design/behavior lane, and update the leaf skill's worklist tokens and Output example accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-06-25 12:37:40 +02:00 committed by GitHub
parent 6140a52b03
commit 13f47f65a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 774 additions and 2 deletions

View file

@ -0,0 +1,38 @@
// Intended for read-only consumption, but the CRUD guards are omitted. With
// InsertAllowed/ModifyAllowed/DeleteAllowed left at their writable defaults the
// endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or
// remove ledger data this API was never meant to expose for writing.
page 50357 "WS Read Only Bad"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'reporting';
APIVersion = 'v1.0';
EntityName = 'customerLedgerEntry';
EntitySetName = 'customerLedgerEntries';
ODataKeyFields = SystemId;
SourceTable = "Cust. Ledger Entry";
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(entryNumber; Rec."Entry No.")
{
Caption = 'entryNumber';
}
field(postingDate; Rec."Posting Date")
{
Caption = 'postingDate';
}
}
}
}
}

View file

@ -0,0 +1,39 @@
page 50356 "WS Read Only Good"
{
PageType = API;
Caption = 'customerLedgerEntry';
APIPublisher = 'contoso';
APIGroup = 'reporting';
APIVersion = 'v1.0';
EntityName = 'customerLedgerEntry';
EntitySetName = 'customerLedgerEntries';
ODataKeyFields = SystemId;
SourceTable = "Cust. Ledger Entry";
Editable = false;
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(entryNumber; Rec."Entry No.")
{
Caption = 'entryNumber';
}
field(postingDate; Rec."Posting Date")
{
Caption = 'postingDate';
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, insertallowed, modifyallowed, deleteallowed, editable, read-only, reporting-api]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Lock down write operations on read-only API pages
## Description
An API meant purely for reading — a reporting or lookup endpoint — is not read-only just because nobody intends to write to it. Unless the page explicitly forbids writes, the platform leaves the endpoint writable, so a client can POST, PATCH, or DELETE against data that was never meant to change through that surface. The fix is explicit: set `InsertAllowed = false`, `ModifyAllowed = false`, and `DeleteAllowed = false` (and `Editable = false`) so the endpoint rejects every write operation. LLMs often assume "I only exposed read fields, so it's read-only" and rely on defaults; this file is remedial because the default for an API page is writable, and the read-only intent has to be encoded as three explicit property settings, not inferred.
## Best Practice
For a read-only / reporting API page set all three CRUD guards off — `InsertAllowed = false`, `ModifyAllowed = false`, `DeleteAllowed = false` — and mark the page `Editable = false`. The endpoint then serves GET requests and rejects any insert, modify, or delete, matching the read-only contract regardless of the caller. Make the read-only stance explicit rather than depending on the writable default.
See sample: `disable-write-operations-on-read-only-api-pages.good.al`.
## Anti Pattern
An API intended for read-only consumption that omits the CRUD guards, leaving `InsertAllowed`, `ModifyAllowed`, and `DeleteAllowed` at their writable defaults. The endpoint silently accepts POST, PATCH, and DELETE, so a client can mutate or remove data the API was never meant to expose for writing. The detection signal: a read-only/reporting `PageType = API` page that does not set the three `*Allowed = false` properties.
See sample: `disable-write-operations-on-read-only-api-pages.bad.al`.

View file

@ -0,0 +1,38 @@
// Committed-only contract, but no isolation level is set. Reads run at the
// default and can observe in-flight, uncommitted writes from concurrent
// transactions. A consumer may fetch a row that is later rolled back a dirty
// read of data that never durably existed.
page 50349 "WS ReadCommitted Bad"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
SourceTable = Customer;
Editable = false;
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,41 @@
page 50348 "WS ReadCommitted Good"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
SourceTable = Customer;
Editable = false;
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
trigger OnOpenPage()
begin
// Return only durably committed rows; ignore concurrent uncommitted writes.
Rec.ReadIsolation := IsolationLevel::ReadCommitted;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [22..]
domain: web-services
keywords: [api-page, readisolation, isolationlevel, readcommitted, onopenpage, dirty-read, committed-data]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Read only committed data from APIs that must not expose in-flight writes
## Description
This is about the data-consistency contract of an API endpoint: what a consumer receives when it reads. By default an API read can return in-flight rows that a concurrent, still-open transaction has written but not yet committed. For an endpoint whose contract is "return only data that is durably committed," that is wrong — a consumer could fetch a row that the writing transaction later rolls back, then act on data that never really existed. From runtime 22.0 (BC 2023 release wave 1) an API page can pin the isolation level its reads use: setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` in the page's `OnOpenPage` trigger makes the endpoint expose only committed rows. LLMs rarely set this on an API page because the platform default "just works" for ordinary UI; this file is remedial because the committed-only endpoint contract requires an explicit opt-in the model would not add on its own.
## Best Practice
For an API page that must expose only committed data, set the endpoint's read isolation once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every read the endpoint then serves ignores uncommitted writes from concurrent transactions, so a consumer never receives a row that another transaction might still roll back.
See sample: `expose-only-committed-data-from-api-reads.good.al`.
## Anti Pattern
An API intended to return committed-only data that sets no isolation level, leaving reads at the default that can observe in-flight, uncommitted writes. A consumer can fetch a row created by a concurrent transaction that is later rolled back — a dirty read that surfaces data which never durably existed. The detection signal: a committed-only read API with no `Rec.ReadIsolation := IsolationLevel::ReadCommitted` in `OnOpenPage`.
See sample: `expose-only-committed-data-from-api-reads.bad.al`.

View file

@ -0,0 +1,60 @@
// Side effect hidden behind a writable flag: PATCHing "posted" to true silently
// triggers posting through OnValidate. The operation is indistinguishable from
// an ordinary data edit and is not discoverable as an action. Expose a
// [ServiceEnabled] bound action instead.
page 50351 "WS Bound Action Bad"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'salesOrder';
EntitySetName = 'salesOrders';
ODataKeyFields = SystemId;
SourceTable = "Sales Header";
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
field(posted; IsPosted)
{
Caption = 'posted';
trigger OnValidate()
var
PostHelper: Codeunit "WS Bound Action Bad Helper";
begin
if IsPosted then
PostHelper.PostOrder(Rec);
end;
}
}
}
}
var
IsPosted: Boolean;
}
codeunit 50353 "WS Bound Action Bad Helper"
{
procedure PostOrder(var SalesHeader: Record "Sales Header")
var
SalesPost: Codeunit "Sales-Post";
begin
SalesPost.Run(SalesHeader);
end;
}

View file

@ -0,0 +1,59 @@
page 50350 "WS Bound Action Good"
{
PageType = API;
Caption = 'salesOrder';
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'salesOrder';
EntitySetName = 'salesOrders';
ODataKeyFields = SystemId;
SourceTable = "Sales Header";
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
}
}
}
[ServiceEnabled]
procedure Post(var ActionContext: WebServiceActionContext)
var
PostHelper: Codeunit "WS Bound Action Helper";
begin
PostHelper.PostOrder(Rec);
SetActionResponse(ActionContext, Rec.SystemId);
end;
local procedure SetActionResponse(var ActionContext: WebServiceActionContext; CreatedId: Guid)
begin
ActionContext.SetObjectType(ObjectType::Page);
ActionContext.SetObjectId(Page::"WS Bound Action Good");
ActionContext.AddEntityKey(Rec.FieldNo(SystemId), CreatedId);
ActionContext.SetResultCode(WebServiceActionResultCode::Updated);
end;
}
codeunit 50352 "WS Bound Action Helper"
{
procedure PostOrder(var SalesHeader: Record "Sales Header")
var
SalesPost: Codeunit "Sales-Post";
begin
SalesPost.Run(SalesHeader);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, serviceenabled, bound-action, webserviceactioncontext, setactionresponse, side-effect, patch]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Expose business operations as bound actions, not as writable status flags
## Description
An API consumer that needs to *do* something to a record — post it, ship it, release it — should call an explicit operation, not mutate a field and hope a side effect fires. AL models this with a bound action: a `[ServiceEnabled] procedure` that takes `var ActionContext: WebServiceActionContext`, performs the work, and reports the result through the action context (typically a `SetActionResponse` helper that returns the affected record's id). The endpoint then exposes a callable action — `.../salesOrders(<id>)/Microsoft.NAV.post` — with a clear contract. The anti-pattern is to expose a writable Boolean or status field whose `OnValidate` quietly performs the operation: a routine PATCH that looks like a data edit silently triggers posting, with no discoverable action and surprising, hard-to-audit behaviour. LLMs reach for the flag-field approach because it is less code; this file is remedial because the platform-idiomatic, contract-safe choice (a bound action) is not the model's default.
## Best Practice
Declare the operation as `[ServiceEnabled] procedure Post(var ActionContext: WebServiceActionContext)` on the API page. Inside, perform the operation against `Rec`, then call a `SetActionResponse` helper that writes the result — the bound record and its id — back into the `WebServiceActionContext` so the caller receives a well-formed response. The operation is now an explicit, named endpoint action separate from ordinary field writes.
See sample: `expose-operations-as-bound-actions.good.al`.
## Anti Pattern
Exposing a writable Boolean (for example `posted`) whose `OnValidate` performs the posting. A client that PATCHes the field to `true` — an action indistinguishable from any other data edit — silently triggers a side-effecting business operation. The detection signal: an API page field whose `OnValidate` posts, ships, or releases, instead of a `[ServiceEnabled]` bound action.
See sample: `expose-operations-as-bound-actions.bad.al`.

View file

@ -0,0 +1,33 @@
// Unstable key: the endpoint addresses records by the business field "No.".
// When a user renames a customer's number, every external reference built on
// the old value dangles. ODataKeyFields should be SystemId instead.
page 50345 "WS SystemId Key Bad"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = "No.";
SourceTable = Customer;
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(number; Rec."No.")
{
Caption = 'number';
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,36 @@
page 50344 "WS SystemId Key Good"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
SourceTable = Customer;
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, odatakeyfields, systemid, stable-key, guid, business-key, editable-false]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Address API records by SystemId, not by a renamable business key
## Description
Every BC table carries a `SystemId` — an immutable GUID assigned at insert and never reused. API consumers must address a record through a key that does not change, otherwise a previously stored URL or `@odata.id` reference breaks the moment a user renames the underlying business key. The convention is to set `ODataKeyFields = SystemId` on the API page and expose the GUID as a non-editable `field(id; Rec.SystemId)`. An LLM left to its own devices often reaches for the human-readable primary key (a customer `No.`, an item code) as the OData key, because that is what a developer types when filtering in AL. That choice is wrong for an external contract: business keys are renamable and the API caller's stored references would dangle. This file is remedial because the correct key (`SystemId`) is rarely the one the model would pick by analogy with ordinary AL code.
## Best Practice
Set `ODataKeyFields = SystemId` so OData routes records by the stable GUID, and expose it as `field(id; Rec.SystemId)` marked `Editable = false`. Clients then address a record at `.../customers(<guid>)`, an identity that survives any rename of the business key. Keep the business key (for example `No.`) as an ordinary exposed field, not as the OData key.
See sample: `expose-systemid-as-the-api-key.good.al`.
## Anti Pattern
Setting `ODataKeyFields = "No."` so the endpoint addresses records by a renamable business field. As soon as a user changes that `No.`, every external reference built on the old value points at nothing, silently breaking integrations. The detection signal: `ODataKeyFields` set to a business field rather than `SystemId`, or an API page that exposes no `id` field bound to `Rec.SystemId`.
See sample: `expose-systemid-as-the-api-key.bad.al`.

View file

@ -0,0 +1,24 @@
// Malformed API endpoint: APIPublisher and APIGroup are missing, and there is
// no SourceTable. The page compiles but the route cannot be composed, so the
// entity is never published where an integration expects it.
page 50341 "WS Required Props Bad"
{
PageType = API;
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
layout
{
area(content)
{
repeater(records)
{
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,36 @@
page 50340 "WS Required Props Good"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
SourceTable = Customer;
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, pagetype-api, apipublisher, apigroup, apiversion, entityname, entitysetname, sourcetable]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Declare every required property on a PageType = API page
## Description
An API page projects a table as an OData v4 / API v2 endpoint, but the platform only publishes that endpoint when the page carries the full set of identifying properties: `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, and a backing `SourceTable`. These properties are what compose the route — `/api/<publisher>/<group>/<version>/<entitySet>` — so omitting any one of them yields a page that compiles yet never surfaces as a usable endpoint, or surfaces at an unexpected address. An LLM that has mostly seen ordinary list/card pages tends to treat `PageType = API` as a cosmetic switch and forgets the identifying metadata, because a normal page needs none of it. This file is remedial precisely because the missing-property failure is silent: there is no runtime error, only an endpoint that clients cannot reach.
## Best Practice
On every `PageType = API` page set all six properties explicitly: `APIPublisher` (your publisher tag), `APIGroup` (the logical grouping for related entities), `APIVersion` (a `vX.Y` value such as `'v1.0'`), `EntityName` (singular), `EntitySetName` (plural), and `SourceTable` (the projected table). Expose the record's fields inside a single `field(...)` repeater under `area(content)`. Treat the six properties as a mandatory checklist that travels with the `PageType = API` declaration itself.
See sample: `set-required-api-page-properties.good.al`.
## Anti Pattern
Writing a page with `PageType = API` and a `SourceTable` but leaving out `APIPublisher` and `APIGroup` (and, worse, omitting `SourceTable` entirely). The page compiles, so it looks finished, but the endpoint is malformed: with no publisher and group the route cannot be composed, and the entity is never published where an integration expects it. The detection signal: a `PageType = API` page missing one or more of the six identifying properties.
See sample: `set-required-api-page-properties.bad.al`.

View file

@ -0,0 +1,35 @@
// Breaking change in place: the published v1.0 is edited rather than versioned.
// EntityName was renamed from 'customer' to 'client' and the displayName field
// was removed, so the single declared version now serves a different contract
// than the one clients integrated against. Every existing consumer breaks.
page 50355 "WS API Versioning Bad"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'client';
EntitySetName = 'clients';
ODataKeyFields = SystemId;
SourceTable = Customer;
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
}
}
}
}

View file

@ -0,0 +1,39 @@
// Additive versioning: v2.0 carries the new shape while v1.0 stays published and
// unchanged. APIVersion accepts a list, so both contracts are served and
// existing clients keep working while new clients adopt v2.0.
page 50354 "WS API Versioning Good"
{
PageType = API;
Caption = 'customer';
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v2.0', 'v1.0';
EntityName = 'customer';
EntitySetName = 'customers';
ODataKeyFields = SystemId;
SourceTable = Customer;
DelayedInsert = true;
layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(number; Rec."No.")
{
Caption = 'number';
}
field(displayName; Rec.Name)
{
Caption = 'displayName';
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, apiversion, versioning, published-contract, breaking-change, backward-compatibility]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Version APIs by adding a new APIVersion, not by mutating a published one
## Description
Once an API version is published, external clients depend on its exact shape — the entity name, the set of exposed fields, the key — as a frozen contract. Changing any of that on the already-published version is a breaking change delivered silently: integrations that worked yesterday fail today with no warning. The platform gives you a clean way to evolve without breaking anyone, because `APIVersion` accepts a *list* of versions on one page. The correct way to change a published API is to add the new version (`'v2.0'`) alongside the existing one (`'v1.0'`) — or publish a new API page for it — so both contracts are served side by side and clients migrate on their own schedule. LLMs tend to "fix" an API by editing the live version in place, because in ordinary code you just change what's wrong; this file is remedial because a published API version is an immutable contract in a way ordinary internal code is not.
## Best Practice
When a published API must change shape, keep the old version's contract intact and add the new one to the `APIVersion` list — `APIVersion = 'v2.0', 'v1.0';`. The page now serves both `v1.0` (unchanged) and `v2.0` (carrying the new shape), so existing clients keep working while new clients adopt `v2.0`. Retire the old version only after consumers have migrated.
See sample: `version-apis-by-adding-not-mutating-published-versions.good.al`.
## Anti Pattern
Editing the published `v1.0` page in place — renaming its `EntityName` or removing an exposed field — so the single declared version now serves a different contract than the one clients integrated against. Every consumer of the old shape breaks without notice. The detection signal: a change that renames the entity or removes a field on an existing published `APIVersion` instead of adding a new version to the list.
See sample: `version-apis-by-adding-not-mutating-published-versions.bad.al`.