Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,8 @@ touches all that apply:
3. `platform_guide`, the MCP instructions and the tool descriptions
(`Api/Mcp`) - for agents; and `AGENTS.md` in every lambda's repository
(`Resources/Export/Agents.md.txt`) for an agent working in a clone.
4. The API description (Scalar at `/api/v1/scalar/`, from the resources).
4. The API description (Scalar at `/api/v1/scalar/`, from the resources) -
see below.

The same rule is worded the same way in all of them.

Expand All @@ -1050,6 +1051,38 @@ database, waiting for tasks, relative paths, secrets, documentation, pages
meant to be found). The rest
is in `platform_guide` and the tool descriptions; do not repeat it there.

### The API description

The OpenAPI document (`/api/v1/openapi.json`, browsed in Scalar) is written for
whoever calls the API - an agent or a developer writing a client - with
GenHTTP's documentation attributes beside the code they describe.

- **Behaviour, not just what - decided by the owner.** An operation says what
it changes and what it leaves alone, every way it is refused and why
(`[Response]` with `ErrorResponse` for the platform's own refusals), what it
answers with (`[return: Summary]`, `[Response]` for 201, 202 and 422), and
bodies and downloads that are not JSON (`[RequestBody]`,
`ContentType`, `[ResponseHeader]`). Read the service before writing it.
- **The attributes for the caller, the XML comments for the maintainer.** A
`[Summary]` and `[Remarks]` say what the caller needs; `<remarks>` keep the
*why* for whoever changes the code. The same sentence is not in both: a
record's fields are described by `[Summary]` on its parameters, not by
`<param>`.
- **A summary is a short label** - Scalar lists the operations by it - and
the rest goes into the remarks.
- **Tags are concepts, not classes**, in the order and groups of
`ApiDocumentation` (`x-tagGroups`); a tag shared by several resources is
described on one of them. The introduction everybody reads first is
`Resources/Api/Introduction.md`. The wording every route shares (the editor
key, a demo's refusal) is a constant of `ApiDocumentation`.
- **Limits the operator sets are never written as numbers**: say where to
read them (`GET /system`, the summary's `limits`). Fixed ones (100 secrets,
32 KB a value) may be.
- `ApiDocumentationTests` refuses an operation without a summary, a parameter,
answer, model or field without a description, a call about a lambda that
does not say how an unknown key is answered, and a tag in no group. A new
endpoint or field is documented in the same change.

## Working conventions

- The code is written to be read: XML documentation with `<remarks>` that say
Expand Down
7 changes: 5 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@

<ItemGroup>

<PackageVersion Include="GenHTTP.Full.Ioxide" Version="11.0.5" />
<PackageVersion Include="GenHTTP.Engine.Kestrel" Version="11.0.5" />
<PackageVersion Include="GenHTTP.Full.Ioxide" Version="11.1.0" />
<PackageVersion Include="GenHTTP.Engine.Kestrel" Version="11.1.0" />

<!-- the tests' client and host; no longer carried by GenHTTP.Full.Ioxide since 11.1 -->
<PackageVersion Include="GenHTTP.Testing" Version="11.1.0" />

<!-- serves every lambda as a git repository, see Services/Git -->
<PackageVersion Include="GenHTTP.Modules.Git" Version="0.1.1" />
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,19 @@ port, each against its own temporary data directory.

### API

Browsable at `/api/v1/scalar/`. A lambda is addressed by its editor key, which
is also what authorizes the call. Things that can be listed, read, created or
changed are resources; what is a process rather than a thing is a verb in the
path.
Browsable at `/api/v1/scalar/`, described by `/api/v1/openapi.json` (or
`.yaml`). The description is the reference for whoever calls the API: it opens
with an introduction (getting started, keys, files, limits, errors), groups
the operations by what they are about, and says of each what it does, what it
leaves alone and every way it is refused. It is written with GenHTTP's
documentation attributes beside the code (see `ApiDocumentation`), and
`ApiDocumentationTests` keeps it complete.

A lambda is addressed by its editor key, which is also what authorizes the
call. Things that can be listed, read, created or changed are resources; what
is a process rather than a thing is a verb in the path. A refusal is answered
with a status and `{ status, error, message }`, where `error` is `Invalid`
(400), `Forbidden` (403), `NotFound` (404), `Conflict` (409) or `TooMany` (429).

| Endpoint | Does |
|-------------------------------------------------------|-------------------------------------------|
Expand Down
146 changes: 79 additions & 67 deletions src/GenHTTP.Lambda/Api/AdminResource.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;

using GenHTTP.Lambda.Api.Infrastructure;
Expand Down Expand Up @@ -32,19 +33,30 @@ namespace GenHTTP.Lambda.Api;
/// action is then the same one the owner would take - so a deployment started
/// here is the same deployment, recorded as the operator's.
/// </remarks>
[Tag(ApiDocumentation.Administration)]
[Summary("Every lambda on the installation, its settings and its limits - for whoever runs it.")]
[Remarks("""
Lambdas are addressed by their **public** key here, and every action is the one the owner would take, recorded as the
operator's. The answers carry editor keys. The settings and the limits apply to the whole installation, from the next
request on.
""")]
[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)]
[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)]
[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)]
public sealed class AdminResource(IMetaService meta, ITelemetryService telemetry, ISettingsService settings, ILimitsService limits,
ILogger<AdminResource> logger)
{

#region Listing

/// <summary>
/// Every lambda, newest first.
/// </summary>
/// <param name="search">Only lambdas whose key or domain contains this</param>
/// <param name="tier">Only lambdas of this tier</param>
[ResourceMethod("lambdas")]
public AdminListingResponse GetLambdas(string? search, string? tier, int page = 1)
[Summary("Lists every lambda")]
[Remarks("Newest first, twenty to a page, with what each has been doing since the server came up, which is counted in memory.")]
[Response(ResponseStatus.BadRequest, "There is no such tier.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("A page of lambdas")]
public AdminListingResponse GetLambdas([Summary("Only lambdas whose public key or domain contains this")] string? search,
[Summary("Only lambdas of this tier: `Free`, `Premium` or `Demo`")] string? tier,
[Summary("The page, counting from 1")] int page = 1)
{
var wanted = Math.Max(1, page);

Expand Down Expand Up @@ -86,41 +98,44 @@ public AdminListingResponse GetLambdas(string? search, string? tier, int page =

#region One lambda

/// <summary>
/// Everything about one lambda: what it is, what it has been doing, its
/// versions and when it was online.
/// </summary>
[ResourceMethod("lambdas/:publicKey")]
public AdminLambdaDetail GetLambda(string publicKey)
[Summary("Reads everything about one lambda")]
[Remarks("What it is - its editor key included - what it has been doing, its versions and when it was online.")]
[Response(ResponseStatus.NotFound, "No lambda has this public key.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("Everything about the lambda")]
public AdminLambdaDetail GetLambda([Summary("The public key of the lambda")] string publicKey)
{

var privateKey = meta.RequirePrivateKey(publicKey);

return Detail(privateKey);
}

/// <summary>
/// The code of one version of a lambda.
/// </summary>
[ResourceMethod("lambdas/:publicKey/versions/:version")]
public VersionContentResponse GetVersion(string publicKey, int version)
[Summary("Reads a version of a lambda with its files")]
[Response(ResponseStatus.NotFound, "No lambda has this public key, or it has no such version.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("The version with its files")]
public VersionContentResponse GetVersion([Summary("The public key of the lambda")] string publicKey, [Summary(ApiDocumentation.VersionNumber)] int version)
{

var content = meta.GetVersion(meta.RequirePrivateKey(publicKey), version);

return VersionResource.Describe(content);
}

/// <summary>
/// Moves a lambda to another tier.
/// </summary>
/// <remarks>
/// The tier decides whether the lambda may answer at a domain of its own
/// and whether the sweeps may take it offline. Leaving the premium tier
/// keeps the domain configured but stops serving it.
/// </remarks>
[ResourceMethod(Method.Put, "lambdas/:publicKey/tier")]
public AdminLambdaDetail ChangeTier(string publicKey, TierRequest body)
[Summary("Moves a lambda to another tier")]
[Remarks("""
The tier decides the limits of the lambda, whether it may answer at a domain of its own, and whether it is taken
offline and deleted when nobody uses it. Leaving the premium tier keeps the domain configured, but stops serving it;
what the lambda has beyond the free limits is kept, and only what it saves next is held to them. Nothing is moved
into or out of the demo tier by hand.
""")]
[Response(ResponseStatus.BadRequest, "There is no such tier.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[Response(ResponseStatus.Forbidden, "The demo tier is the installation's own. Nothing is moved into or out of it by hand.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[Response(ResponseStatus.NotFound, "No lambda has this public key.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("The lambda afterwards")]
public AdminLambdaDetail ChangeTier([Summary("The public key of the lambda")] string publicKey, TierRequest body)
{

var privateKey = meta.RequirePrivateKey(publicKey);
Expand All @@ -132,11 +147,14 @@ public AdminLambdaDetail ChangeTier(string publicKey, TierRequest body)
return Detail(privateKey);
}

/// <summary>
/// Sets or removes the domain of a lambda, the way its owner would.
/// </summary>
[ResourceMethod(Method.Put, "lambdas/:publicKey/domain")]
public AdminLambdaDetail ChangeDomain(string publicKey, DomainChangeRequest body)
[Summary("Sets the domain of a lambda")]
[Remarks("Under the same rules as `PUT /lambdas/{privateKey}/domain`: only a premium lambda may have one. Nothing, or an empty domain, removes it.")]
[Response(ResponseStatus.BadRequest, "That is not a domain that can be used.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[Response(ResponseStatus.Conflict, "Another lambda answers at that domain.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[Response(ResponseStatus.NotFound, "No lambda has this public key.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("The lambda afterwards")]
public AdminLambdaDetail ChangeDomain([Summary("The public key of the lambda")] string publicKey, DomainChangeRequest body)
{

var privateKey = meta.RequirePrivateKey(publicKey);
Expand All @@ -148,15 +166,13 @@ public AdminLambdaDetail ChangeDomain(string publicKey, DomainChangeRequest body
return Detail(privateKey);
}

/// <summary>
/// Puts a version online - the newest, when none is named.
/// </summary>
/// <remarks>
/// Code that does not compile is answered with 422 and what the compiler
/// said, and whatever was online before stays online.
/// </remarks>
[ResourceMethod(Method.Post, "lambdas/:publicKey/deployment/start")]
public async ValueTask<Result<DeploymentOutcomeResponse>> StartDeployment(string publicKey, DeploymentRequest? body)
[Summary("Puts a version of a lambda online")]
[Remarks("The newest, unless another is named - as its owner would, but for demos as well, and recorded as the operator's.")]
[Response(ResponseStatus.Ok, "The version is online.")]
[Response(ResponseStatus.UnprocessableEntity, ApiDocumentation.Diagnostics, Type = typeof(DeploymentOutcomeResponse))]
[Response(ResponseStatus.NotFound, "No lambda has this public key, or it has no such version.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public async ValueTask<Result<DeploymentOutcomeResponse>> StartDeployment([Summary("The public key of the lambda")] string publicKey, DeploymentRequest? body)
{

var result = await meta.DeployAsync(meta.RequirePrivateKey(publicKey), body?.Version, VersionOrigins.Admin);
Expand All @@ -168,11 +184,12 @@ public async ValueTask<Result<DeploymentOutcomeResponse>> StartDeployment(string
return new Result<DeploymentOutcomeResponse>(payload).Status(result.Success ? ResponseStatus.Ok : ResponseStatus.UnprocessableEntity);
}

/// <summary>
/// Takes a lambda offline without removing it.
/// </summary>
[ResourceMethod(Method.Post, "lambdas/:publicKey/deployment/stop")]
public LambdaOverviewResponse StopDeployment(string publicKey)
[Summary("Takes a lambda offline without deleting it")]
[Remarks("Its history says the operator took it down. Its owner may deploy it again.")]
[Response(ResponseStatus.NotFound, "No lambda has this public key.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("The lambda afterwards")]
public LambdaOverviewResponse StopDeployment([Summary("The public key of the lambda")] string publicKey)
{

var lambda = meta.Undeploy(meta.RequirePrivateKey(publicKey), ActivationEndings.Admin);
Expand All @@ -182,11 +199,12 @@ public LambdaOverviewResponse StopDeployment(string publicKey)
return Summarize(lambda);
}

/// <summary>
/// Removes a lambda and everything stored for it.
/// </summary>
[ResourceMethod(Method.Delete, "lambdas/:publicKey")]
public void Delete(string publicKey)
[Summary("Deletes a lambda and everything kept for it")]
[Remarks("As its owner would, for good. Demos cannot be deleted: the installation keeps them.")]
[Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
[Response(ResponseStatus.NotFound, "No lambda has this public key.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public void Delete([Summary("The public key of the lambda")] string publicKey)
{

meta.Delete(meta.RequirePrivateKey(publicKey));
Expand All @@ -198,21 +216,20 @@ public void Delete(string publicKey)

#region Settings

/// <summary>
/// What the operator has switched on or off.
/// </summary>
[ResourceMethod("settings")]
[Summary("Reads what the operator has switched on or off")]
[return: Summary("The settings of the installation")]
public SettingsModel GetSettings()
{
var current = settings.Get();

return new SettingsModel(current.EnterprisePage, current.BuildBox, current.ChangeBox);
}

/// <summary>
/// Replaces the settings. They apply to the next page anybody opens.
/// </summary>
[ResourceMethod(Method.Put, "settings")]
[Summary("Replaces the settings")]
[Remarks("All of them. They apply to the next page anybody opens.")]
[return: Summary("The settings, as they were saved")]
public SettingsModel ChangeSettings(SettingsModel body)
{
var saved = settings.Save(new SiteSettings(body.EnterprisePage, body.BuildBox, body.ChangeBox));
Expand All @@ -227,27 +244,22 @@ public SettingsModel ChangeSettings(SettingsModel body)

#region Limits

/// <summary>
/// What a lambda may have and do, per tier.
/// </summary>
/// <remarks>
/// Where the operator has not saved them yet, these are the defaults - the
/// built in ones, or what the environment variables that used to set them
/// say.
/// </remarks>
[ResourceMethod("limits")]
[Summary("Reads what a lambda may have and do, per tier")]
[Remarks("Where the operator has not saved them yet, these are the defaults - the built in ones, or what the environment variables that used to set them say.")]
[return: Summary("The limits of the installation")]
public LimitsModel GetLimits() => Describe(limits.Get());

/// <summary>
/// Replaces the limits, all of them.
/// </summary>
/// <remarks>
/// They apply to what is checked next - a save, an upload, a deploy, a
/// connection to a database - and take nothing away that a lambda already
/// has. Every value must be greater than zero, and the premium tier must
/// not allow less than the free one.
/// </remarks>
[ResourceMethod(Method.Put, "limits")]
[Summary("Replaces the limits, all of them")]
[Remarks("""
They apply to what is checked next - a save, an upload, a deploy, a connection to a database - and take nothing away
that a lambda already has. Every value must be greater than zero, no tier may show more days of analytics than are
kept (a year), and the premium tier must not allow less than the free one.
""")]
[Response(ResponseStatus.BadRequest, "A value is zero or less, more days of analytics than are kept, or the premium tier would allow less than the free one. Nothing was changed.",
Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
[return: Summary("The limits, as they were saved")]
public LimitsModel ChangeLimits(LimitsModel body)
{
var (saved, previous) = limits.Save(new ProductLimits(
Expand Down
Loading
Loading