mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-07 01:46:53 +01:00
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>
This commit is contained in:
parent
6140a52b03
commit
2da72e782a
27 changed files with 965 additions and 2 deletions
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// Writable API page with no DelayedInsert: on a POST the platform may insert the
|
||||
// row on the first assigned field, before the rest of the payload is applied,
|
||||
// failing validation that needs later fields or persisting a partial record.
|
||||
page 50347 "WS DelayedInsert Bad"
|
||||
{
|
||||
PageType = API;
|
||||
APIPublisher = 'contoso';
|
||||
APIGroup = 'sales';
|
||||
APIVersion = 'v1.0';
|
||||
EntityName = 'customer';
|
||||
EntitySetName = 'customers';
|
||||
ODataKeyFields = SystemId;
|
||||
SourceTable = Customer;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
page 50346 "WS DelayedInsert 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: web-services
|
||||
keywords: [api-page, delayedinsert, writable-api, insert-trigger, mandatory-fields, partial-record]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set DelayedInsert = true on writable API pages
|
||||
|
||||
## Description
|
||||
|
||||
When a client POSTs a new record to an API page, the platform receives the posted fields one at a time. Without `DelayedInsert = true`, the page can insert the row as soon as the first field is assigned — before the remaining fields in the payload have been applied. That premature insert fails mandatory-field and table validation that depends on fields arriving later, or it persists a partial record that violates the table's invariants. Setting `DelayedInsert = true` defers the actual `Insert` until every posted field has been assigned, so validation sees the complete record. The official API page template ships with this property set. LLMs that generate an API page by analogy with an ordinary editable page routinely omit it, because interactive pages do not need it; this file is remedial because the default is a footgun specific to the API/POST flow.
|
||||
|
||||
## Best Practice
|
||||
|
||||
On any API page that accepts inserts (a writable entity), set `DelayedInsert = true`. The platform then collects all posted fields and inserts the row once, after assignment is complete, so mandatory-field checks and `OnInsert` validation run against the full record. Treat `DelayedInsert = true` as a default for every writable API page, exactly as the standard API page template does.
|
||||
|
||||
See sample: `enable-delayedinsert-on-writable-api-pages.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A writable API page that omits `DelayedInsert`. On a POST the platform may insert the row on the first assigned field, before the rest of the payload lands — failing validation that needs the later fields, or leaving a half-populated record behind. The detection signal: a writable `PageType = API` page (one that allows inserts) with no `DelayedInsert = true`.
|
||||
|
||||
See sample: `enable-delayedinsert-on-writable-api-pages.bad.al`.
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
By default a read can observe uncommitted changes made by other, still-open transactions running concurrently. For most interactive pages that is harmless, but an API whose contract is "return only data that is durably committed" must not leak those dirty reads — a consumer could fetch a row that a concurrent transaction later rolls back, and act on data that never really existed. From runtime 22.0 (BC 2023 release wave 1) AL exposes `Rec.ReadIsolation`, letting a page pin its isolation level. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` in `OnOpenPage` guarantees the endpoint only returns committed rows. LLMs rarely set isolation explicitly because the platform's default "just works" for ordinary UI; this file is remedial because committed-only API semantics require an explicit opt-in that the model would not add on its own.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For an API page that must expose only committed data, set the isolation level once as the page opens: in the `OnOpenPage` trigger write `Rec.ReadIsolation := IsolationLevel::ReadCommitted;`. Every subsequent read on that record variable then ignores uncommitted writes from concurrent transactions, so the endpoint never returns 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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Swapped and miscased entity names: EntityName is plural and PascalCase while
|
||||
// EntitySetName is singular. The single-record name reads as a collection and
|
||||
// vice versa, and the leading capitals violate camelCase.
|
||||
page 50343 "WS Entity Naming Bad"
|
||||
{
|
||||
PageType = API;
|
||||
APIPublisher = 'contoso';
|
||||
APIGroup = 'sales';
|
||||
APIVersion = 'v1.0';
|
||||
EntityName = 'Customers';
|
||||
EntitySetName = 'Customer';
|
||||
ODataKeyFields = SystemId;
|
||||
SourceTable = Customer;
|
||||
DelayedInsert = true;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(records)
|
||||
{
|
||||
field(id; Rec.SystemId)
|
||||
{
|
||||
Caption = 'id';
|
||||
Editable = false;
|
||||
}
|
||||
field(displayName; Rec.Name)
|
||||
{
|
||||
Caption = 'displayName';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
page 50342 "WS Entity Naming 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(displayName; Rec.Name)
|
||||
{
|
||||
Caption = 'displayName';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: web-services
|
||||
keywords: [api-page, entityname, entitysetname, camelcase, singular-plural, apiversion, naming-convention]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Name API entities with a singular EntityName and a plural EntitySetName, both camelCase
|
||||
|
||||
## Description
|
||||
|
||||
API v2 entity names follow a strict convention: `EntityName` is the singular noun for one record (`customer`), `EntitySetName` is its plural for the collection (`customers`), and both are camelCase — a lowercase first letter, no spaces or underscores. `APIVersion` follows the `vX.Y` shape (`v1.0`). The platform treats these as contract-shaping rules, not cosmetics: a casing violation raises a compiler warning and a naming violation (using a plural where the singular is expected, or PascalCase where camelCase is expected) is flagged as an error. LLMs frequently default to BC's PascalCase object-naming habit (`Customer`, `SalesOrders`) and carry it into these properties, producing client-facing entity names that read wrong and trip the analyzer. This file is remedial because the singular/plural split is the opposite of how a developer names AL objects.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Pick the singular camelCase noun for `EntityName` and its plural for `EntitySetName`: `EntityName = 'customer'` with `EntitySetName = 'customers'`; `EntityName = 'salesOrder'` with `EntitySetName = 'salesOrders'`. Keep the first letter lowercase and use camelCase for compound names. Set `APIVersion` to a `vX.Y` literal such as `'v1.0'`. The pair should read naturally in a URL: one `customer`, a set of `customers`.
|
||||
|
||||
See sample: `name-api-entities-with-singular-and-plural-camelcase.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Swapping and miscasing the pair — `EntityName = 'Customers'` (plural and PascalCase) with `EntitySetName = 'Customer'` (singular). The single-record name now reads as a collection, the collection name reads as one record, and the leading capitals violate camelCase. The detection signal: an `EntityName` that is plural or starts with an uppercase letter, or an `EntitySetName` that is singular.
|
||||
|
||||
See sample: `name-api-entities-with-singular-and-plural-camelcase.bad.al`.
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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. Changing any of that on the published version is a breaking change delivered silently: existing integrations that worked yesterday fail today with no warning. AL supports versioning directly because `APIVersion` accepts a *list* of versions on the same page. The correct way to evolve a published API is to add a new version (`'v2.0'`) alongside the existing one (`'v1.0'`) so both contracts are served, letting 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 contract is immutable in a way ordinary internal code is not. This is API-specific versioning and complements, without duplicating, the general breaking-changes domain.
|
||||
|
||||
## 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue