ZuloOne
English
Sign in
Menu

🌐 A model's web services

Updated

A web service is an operation the business layer exposes externally: an endpoint of your own, with a typed contract and a C# body, that lives in a model and not in the platform core. It responds at /api/rest/{Name}.

← Developer wiki

Why it exists#

A controller in the platform core is platform code, and it is shipped to every tenant. A country or customer HTTP operation is not a platform-core route: ZATCA onboarding is not ZatcaDevController compiled into every tenant. A web service is a model object, ships with the model, and is deleted with the model.

What it is made of#

File What it carries
WebServices/<Name>/<Name>.webservice.json the object plus parameters (the contract)
WebServices/<Name>/<Name>Service.script.json the script binding
WebServices/<Name>/<Name>Service.cs the body (the user partial)

Out of parameters the platform generates <Name>Request / <Name>Response and the partial <Name>Service : WebServiceScriptBase<<Name>Request> — the same generated-half pattern as other scripts. The user partial overrides the verb:

public partial class ZatcaCsrService
{
    public override Task<object?> Any(ZatcaCsrRequest request)
    {
        var result = ScriptServices.Get<IZatcaCsr>().Create(
            request.VatNumber ?? "", request.Organization ?? "");
        return Task.FromResult<object?>(new ZatcaCsrResponse
        {
            Ok = true,
            Csr = result["csrPem"],
        });
    }
}

Get / Post / Put / Delete fall through to Any by default; override Any when the verb does not matter. ListResponse on the object makes the answer a LIST of instances of the response class.

The handlers are asynchronous. Almost everything a service does is a Task, and a synchronous signature left the author with nothing but .GetAwaiter().GetResult() on the request thread. Returning Task.FromResult(...) from a handler that has nothing to await is free.

parameters is replaced WHOLESALE. A PUT from the designer with a body that carries no "parameters" is not a partial update of the flags but an erasure of the contract: the DTOs become empty classes and the body stops compiling. The integration test ZatcaOnboardingEndpointsTest holds exactly that (the request and the response are non-empty).

Who may call it#

Three independent switches on the object.

Flag Default What it does
isEnabled true A disabled service disappears from both routing and OpenAPI, and responds exactly like an unknown name. The caller never learns that the service exists.
isDevOnly false Responds only on a stand with ZuloOne:CoreDevelopmentMode — like [CoreDevOnly] on a controller. In production it is indistinguishable from one that does not exist.
requireExplicitRoles false Requires at least one explicit Execute grant before anybody can call it.

The last one exists because the permission matrix on an object is opt-in: an object with no rows is PUBLIC to any authenticated caller (PermissionPolicy.AllowOnEmptyMatrix). For a report that is the right default; for an operation it is not, and the flag lets a service demand the opposite for itself without changing the platform's policy.

For operations that issue or return a SECRET (the private key of a CSR, a CSID token), isDevOnly plus requireExplicitRoles is exactly the difference between a developer's tool and an open door. Without those flags the endpoint cannot live as a tenant web service.

A response that is not 200#

The handler returns the body; an exception sets the status:

throw new WebServiceStatusException(502, new { error = "upstream unavailable" });

Without it, a service can return only 200, and an upstream 4xx or a broken transport turns into a "success" with the error buried in the body. NotImplementedException the runtime maps to 501 anyway.

What a service has access to#

GetService<T>() / TryGetService<T>() — as in any script, the models' I<Name> contracts included. The same deny-list applies (ScriptServices.EnsureAllowed): IOutboundTransport and ICredentialResolver are not provided to a script — the host is what puts the bytes on the wire through IOutboundGateway.EnqueueAsync, and the host is what resolves the secret, on its own thread. The full explanation of the boundary is what a script can do.

The whole request: Headers and RawBody#

The ordinary input is the bound DTO, and most services need nothing more. It is not enough when the endpoint imitates another system's API: a one-time OTP header, the Basic credential presented, an idempotency key, a signature computed over the bytes as they arrived — none of that is a field of the JSON body.

var otp = Headers.TryGetValue("OTP", out var v) ? v : "";   // case does not matter
var raw = RawBody ?? "";                                     // the body verbatim

Cookie is withheld deliberately: a model's service has no use for the caller's session, and it is the one header whose leak lets a caller impersonate the browser. Authorization is passed through — an endpoint that checks a presented credential does not work without it.

Also on the base: the UserId and UserName of the authenticated caller.

Reading the metadata from a script#

IMetadataService (namespace ZuloOne.Core.Services):

Task<IEnumerable<MetaWebService>>          GetWebServicesAsync();
Task<MetaWebService?>                      GetWebServiceAsync(Guid metaId);
Task<IEnumerable<MetaWebServiceParameter>> GetWebServiceParametersAsync(Guid metaId);

This is how a model checks its own endpoints with an integration test — that the service is in place, that it has a body and a contract, and that the access flags have not moved.

Checking it#

curl -s http://localhost:5257/api/rest/<Name>              # GET
curl -s -X POST http://localhost:5257/api/rest/<Name> \
     -H "Content-Type: application/json" -d '{ … }'

Compiling a model that has a web service is part of the ordinary POST /api/metadata/models/compile.

See also#

Where next#

Was this page helpful?