diff --git a/CLAUDE.md b/CLAUDE.md
index f87f9855..ee525e37 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
@@ -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; `` 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
+ ``.
+- **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 `` that say
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 661b1f9b..11d8b435 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,8 +7,11 @@
-
-
+
+
+
+
+
diff --git a/README.md b/README.md
index c5508f22..2c01ec64 100644
--- a/README.md
+++ b/README.md
@@ -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 |
|-------------------------------------------------------|-------------------------------------------|
diff --git a/src/GenHTTP.Lambda/Api/AdminResource.cs b/src/GenHTTP.Lambda/Api/AdminResource.cs
index 9d6e8107..10d99dea 100644
--- a/src/GenHTTP.Lambda/Api/AdminResource.cs
+++ b/src/GenHTTP.Lambda/Api/AdminResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -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.
///
+[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 logger)
{
#region Listing
- ///
- /// Every lambda, newest first.
- ///
- /// Only lambdas whose key or domain contains this
- /// Only lambdas of this tier
[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);
@@ -86,12 +98,12 @@ public AdminListingResponse GetLambdas(string? search, string? tier, int page =
#region One lambda
- ///
- /// Everything about one lambda: what it is, what it has been doing, its
- /// versions and when it was online.
- ///
[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);
@@ -99,11 +111,11 @@ public AdminLambdaDetail GetLambda(string publicKey)
return Detail(privateKey);
}
- ///
- /// The code of one version of a lambda.
- ///
[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);
@@ -111,16 +123,19 @@ public VersionContentResponse GetVersion(string publicKey, int version)
return VersionResource.Describe(content);
}
- ///
- /// Moves a lambda to another tier.
- ///
- ///
- /// 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.
- ///
[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);
@@ -132,11 +147,14 @@ public AdminLambdaDetail ChangeTier(string publicKey, TierRequest body)
return Detail(privateKey);
}
- ///
- /// Sets or removes the domain of a lambda, the way its owner would.
- ///
[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);
@@ -148,15 +166,13 @@ public AdminLambdaDetail ChangeDomain(string publicKey, DomainChangeRequest body
return Detail(privateKey);
}
- ///
- /// Puts a version online - the newest, when none is named.
- ///
- ///
- /// Code that does not compile is answered with 422 and what the compiler
- /// said, and whatever was online before stays online.
- ///
[ResourceMethod(Method.Post, "lambdas/:publicKey/deployment/start")]
- public async ValueTask> 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> StartDeployment([Summary("The public key of the lambda")] string publicKey, DeploymentRequest? body)
{
var result = await meta.DeployAsync(meta.RequirePrivateKey(publicKey), body?.Version, VersionOrigins.Admin);
@@ -168,11 +184,12 @@ public async ValueTask> StartDeployment(string
return new Result(payload).Status(result.Success ? ResponseStatus.Ok : ResponseStatus.UnprocessableEntity);
}
- ///
- /// Takes a lambda offline without removing it.
- ///
[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);
@@ -182,11 +199,12 @@ public LambdaOverviewResponse StopDeployment(string publicKey)
return Summarize(lambda);
}
- ///
- /// Removes a lambda and everything stored for it.
- ///
[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));
@@ -198,10 +216,9 @@ public void Delete(string publicKey)
#region Settings
- ///
- /// What the operator has switched on or off.
- ///
[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();
@@ -209,10 +226,10 @@ public SettingsModel GetSettings()
return new SettingsModel(current.EnterprisePage, current.BuildBox, current.ChangeBox);
}
- ///
- /// Replaces the settings. They apply to the next page anybody opens.
- ///
[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));
@@ -227,27 +244,22 @@ public SettingsModel ChangeSettings(SettingsModel body)
#region Limits
- ///
- /// What a lambda may have and do, per tier.
- ///
- ///
- /// 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.
- ///
[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());
- ///
- /// Replaces the limits, all of them.
- ///
- ///
- /// 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.
- ///
[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(
diff --git a/src/GenHTTP.Lambda/Api/BuildResource.cs b/src/GenHTTP.Lambda/Api/BuildResource.cs
index 892bf0ee..2e9d037b 100644
--- a/src/GenHTTP.Lambda/Api/BuildResource.cs
+++ b/src/GenHTTP.Lambda/Api/BuildResource.cs
@@ -1,5 +1,8 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
+using GenHTTP.Lambda.Api.Infrastructure;
+
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Building;
@@ -24,18 +27,40 @@ namespace GenHTTP.Lambda.Api;
/// What is on this side is the counting: a few builds per address per day,
/// because each one spends somebody's subscription.
///
+[Tag(ApiDocumentation.Builds)]
+[Summary("The build agent of the installation: a sentence in, a new lambda out - for anybody, without a key.")]
+[Remarks("""
+ What `/build` on this site does. Somebody says what they would like; the agent of the installation creates a lambda
+ for it, writes it, and puts it online, and the build's result names the address and the editor key. It only builds
+ applications: a request that is not one, or is about the platform rather than an app on it, or is meant to do harm,
+ is declined with a sentence saying why.
+
+ Whether this installation builds anything at all is in `GET /system` (`build`), with how many builds one address
+ may ask for a day. Each build is counted against that allowance, which starts again at midnight UTC and is shared
+ with the changes asked for in the editor. An agent may also be reached directly through MCP at `/mcp`.
+
+ Errors here carry `status` and `message` only.
+ """)]
public sealed class BuildResource(IBuildService builds, ILogger logger)
{
- ///
- /// Asks for something to be built.
- ///
///
/// Whether this installation builds anything at all is part of
/// /system, so a page can decide whether to offer the box rather
/// than offering one that answers with an error.
///
[ResourceMethod(Method.Post)]
+ [Summary("Asks the build agent to build something")]
+ [Remarks("""
+ The build is queued and runs in the background; `GET /builds/{id}` follows it. The prompt is logged in full, so the
+ operator knows what people ask for - put nothing into it that should not be read. It is cut past 2000 characters.
+ """)]
+ [Response(ResponseStatus.Accepted, "The build was taken on. Follow it by its `id`.")]
+ [Response(ResponseStatus.BadRequest, "The prompt says too little, or there is no such model.")]
+ [Response(ResponseStatus.Forbidden, "The password for the second model is not right.")]
+ [Response(ResponseStatus.NotFound, "This installation builds nothing: it has no agent, or the operator switched building off.")]
+ [Response(ResponseStatus.TooManyRequests, "This address asked for as many builds today as one may.")]
+ [Response(ResponseStatus.ServiceUnavailable, "The agent is not answering. Nothing was counted.")]
public async ValueTask> Create(BuildRequest body, IRequest request)
{
var started = await builds.StartAsync(body?.Prompt, body?.Model, body?.Password, request.Client.Address);
@@ -47,10 +72,16 @@ public async ValueTask> Create(BuildRequest body, IRequest
return new Result(started).Status(ResponseStatus.Accepted);
}
- ///
- /// How a build is getting on, polled while it runs.
- ///
[ResourceMethod(":id")]
- public async ValueTask Get(string id) => await builds.ProgressAsync(id);
+ [Summary("Reads how a build is getting on")]
+ [Remarks("""
+ Asked again while it runs: its place in the queue, the steps the agent took so far, and once it is done how it
+ ended. The result of a build that worked holds the editor key of the new lambda, so whoever has the id of a build
+ can take the lambda it made - keep it as private as the key. The agent forgets a build some time after it ended.
+ """)]
+ [Response(ResponseStatus.BadRequest, "That is not the id of a build.")]
+ [Response(ResponseStatus.NotFound, "The agent knows no build of this id, any more - or this installation has no agent.")]
+ [return: Summary("Its state, its steps, and how it ended once it has")]
+ public async ValueTask Get([Summary("The `id` of the build, as starting it returned")] string id) => await builds.ProgressAsync(id);
}
diff --git a/src/GenHTTP.Lambda/Api/CodeResource.cs b/src/GenHTTP.Lambda/Api/CodeResource.cs
index 481d0e20..9b4df7ca 100644
--- a/src/GenHTTP.Lambda/Api/CodeResource.cs
+++ b/src/GenHTTP.Lambda/Api/CodeResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Infrastructure;
@@ -10,34 +13,46 @@
namespace GenHTTP.Lambda.Api;
-///
-/// Questions the editor asks the compiler about code that has not been saved.
-///
///
/// Nothing here is stored and nothing is a resource of its own, so each
/// question is a verb that takes the code with it. They all sit behind the
/// editor key regardless, because they run the compiler: an endpoint that
/// binds arbitrary C# for anyone who asks is a way to spend a server.
///
+[Tag(ApiDocumentation.Code)]
+[Summary("Questions for the compiler about code that has not been saved: does it compile, and what does a name mean.")]
+[Remarks("""
+ Each takes the files with it, as a save would, and stores nothing. They are behind the editor key of a lambda because
+ they run the compiler, against the references and the rules a lambda is compiled with - so code that passes the
+ check compiles when it is deployed. Positions count lines and columns from zero.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class CodeResource(IMetaService meta)
{
- ///
- /// Compiles the code without storing or deploying it.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/code/check")]
- public async ValueTask Check(string privateKey, CodeRequest request)
+ [Summary("Compiles code without saving it")]
+ [Remarks("""
+ Exactly as a deployment would compile them, including the checks of what a lambda may not do, but without running
+ anything. Code that does not compile is not an error of the request: it is answered with `200` and
+ `success: false`. The files are held to the same rules as a save.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The files cannot be a lambda as they are - `lambda.cs` missing or not first, a name that cannot be used - or they are larger than a version may be.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("Whether it compiles, and what the compiler said")]
+ public async ValueTask Check([Summary(ApiDocumentation.PrivateKey)] string privateKey, CodeRequest request)
{
var outcome = await meta.CheckAsync(privateKey, VersionResource.Serialize(request.Files));
return new CompilationResponse(outcome.Success, outcome.Diagnostics);
}
- ///
- /// What every name in a file means, for the colours in the editor.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/code/semantics")]
- public async ValueTask Semantics(string privateKey, CodeRequest request)
+ [Summary("Classifies the names in a file")]
+ [Remarks("For the file named by `file`, the first one if none is: where each name is, and what the compiler resolved it to.")]
+ [Response(ResponseStatus.BadRequest, "There is no file of that name in the request.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("Every name of the file, and what it means")]
+ public async ValueTask Semantics([Summary(ApiDocumentation.PrivateKey)] string privateKey, CodeRequest request)
{
meta.RequireId(privateKey);
@@ -48,11 +63,12 @@ public async ValueTask Semantics(string privateKey, CodeReque
return new SemanticsResponse([.. tokens.Select(t => new SemanticToken(t.Line, t.Column, t.Length, t.Kind))]);
}
- ///
- /// What could be written where the caret is.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/code/completions")]
- public async ValueTask Completions(string privateKey, CodeRequest request)
+ [Summary("Suggests what could be written")]
+ [Remarks("At `line` and `column` of the file named by `file`: the members, types and keywords the compiler would accept there.")]
+ [Response(ResponseStatus.BadRequest, "There is no file of that name in the request.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("What could be written there")]
+ public async ValueTask Completions([Summary(ApiDocumentation.PrivateKey)] string privateKey, CodeRequest request)
{
meta.RequireId(privateKey);
@@ -61,9 +77,6 @@ public async ValueTask Completions(string privateKey, CodeR
return new CompletionsResponse([.. found.Select(c => new ResolvedCompletionResponse(c.Label, c.Kind, c.Detail, c.Documentation))]);
}
- ///
- /// Where the name under the caret was declared.
- ///
///
/// Only ever answers with a file of this lambda. A name that came from the
/// framework has a declaration, but not one anybody here can be shown, and
@@ -71,7 +84,10 @@ public async ValueTask Completions(string privateKey, CodeR
/// it there is nowhere to go.
///
[ResourceMethod(Method.Post, "lambdas/:privateKey/code/definition")]
- public async ValueTask Definition(string privateKey, CodeRequest request)
+ [Summary("Finds where a name was declared")]
+ [Remarks("Only ever in a file of the request. A name declared elsewhere - by the framework, say - is answered with no file.")]
+ [return: Summary("Where the name was declared, or no file where that is in none of them")]
+ public async ValueTask Definition([Summary(ApiDocumentation.PrivateKey)] string privateKey, CodeRequest request)
{
meta.RequireId(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/DataResource.cs b/src/GenHTTP.Lambda/Api/DataResource.cs
index 208a66f0..33248c67 100644
--- a/src/GenHTTP.Lambda/Api/DataResource.cs
+++ b/src/GenHTTP.Lambda/Api/DataResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Model;
@@ -12,52 +13,60 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The data of a lambda: which kinds it keeps, and switching them on and off.
-///
///
-/// A version is the program - its code and resources - and is replaced by the
-/// next one; data is what the program keeps, shared by every version and left
-/// alone by deploying and rolling back. The database is off until it is
-/// switched on, which makes it; its tables are under
-/// /lambdas/{privateKey}/database. The workspace is on unless its owner
-/// switched it off; its files are under /lambdas/{privateKey}/files.
-/// The secrets are off until they are switched on; their names are under
-/// /lambdas/{privateKey}/secrets. Kinds that come later are listed here
-/// the same way, each switched on before the lambda can use it.
+/// Kinds that come later are listed here the same way, each switched on
+/// before the lambda can use it.
///
+[Tag(ApiDocumentation.Data)]
+[Summary("What a lambda keeps: which kinds of data it has, and switching them on and off.")]
+[Remarks("""
+ A version is the program and is replaced by the next one. Data is what the program keeps, shared by every version
+ and left alone by deploying, rolling back and merging. There are three kinds:
+
+ - `database`: a SQLite database, which the code reaches with `Database.GetConnection()`. Off until it is switched
+ on. Its tables are under `…/database`.
+ - `workspace`: files the code reads and writes. On unless its owner switched it off. They are under `…/files`.
+ - `secrets`: API keys and passwords the code reads with `Secret.Read("NAME")`, which nobody else can read. Off
+ until they are switched on. Their names are under `…/secrets`.
+
+ A kind is switched on before the lambda can use it, and the lambda can use it from its next request on, without
+ being deployed again. **Switching a kind off deletes everything it held**, the copies of the lambda's features
+ included, and cannot be undone. Code that uses a kind while it is off fails with an exception that says so.
+
+ The database and the workspace share one allowance, the data allowance of the lambda's tier.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class DataResource(IDataService data, IMetaService meta, ILogger logger)
{
- ///
- /// Every kind of data there is, and how the lambda has it.
- ///
[ResourceMethod("lambdas/:privateKey/data")]
- public List List(string privateKey)
+ [Summary("Lists the kinds of data")]
+ [return: Summary("Every kind of data")]
+ public List List([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> [.. (data.List(privateKey)).Select(Describe)];
- ///
- /// One kind of data, and how the lambda has it.
- ///
- /// Which kind: database, workspace or secrets
[ResourceMethod("lambdas/:privateKey/data/:kind")]
- public DataStoreResponse Get(string privateKey, string kind)
+ [Summary("Reads one kind of data")]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or there is no such kind of data.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The kind of data")]
+ public DataStoreResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("Which kind: `database`, `workspace` or `secrets`")] string kind)
=> Describe(data.Get(privateKey, kind));
- ///
- /// Switches a kind of data on.
- ///
- ///
- /// Nothing happens if it is on already. The lambda can use it from its
- /// next request on, without being deployed again. The database is made
- /// empty, and the lambda is started again on its next request, so what it
- /// does with its database as it starts - migrating it - is done. The
- /// secrets start empty: their values are set under
- /// /lambdas/{privateKey}/secrets.
- ///
- /// Which kind: database, workspace or secrets
[ResourceMethod(Method.Put, "lambdas/:privateKey/data/:kind")]
- public DataStoreResponse Enable(string privateKey, string kind)
+ [Summary("Switches a kind of data on")]
+ [Remarks("""
+ Nothing happens if it is on already. The lambda can use it from its next request on, without being deployed again.
+
+ The database is made empty, and the lambda is started again on its next request, so what it does as it starts -
+ applying its migrations - runs against it. The secrets start empty: their values are entered with
+ `PUT …/secrets/{name}`.
+ """)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or there is no such kind of data.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The kind of data, switched on")]
+ public DataStoreResponse Enable([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("Which kind: `database`, `workspace` or `secrets`")] string kind)
{
var store = data.Enable(privateKey, kind);
@@ -66,16 +75,19 @@ public DataStoreResponse Enable(string privateKey, string kind)
return Describe(store);
}
- ///
- /// Switches a kind of data off, and deletes everything it held.
- ///
- ///
- /// This cannot be undone. From its next request on, code that uses it is
- /// refused with an exception saying that it is off.
- ///
- /// Which kind: database, workspace or secrets
[ResourceMethod(Method.Delete, "lambdas/:privateKey/data/:kind")]
- public DataStoreResponse Disable(string privateKey, string kind)
+ [Summary("Switches a kind of data off and deletes it")]
+ [Remarks("""
+ **This cannot be undone.** Every table, file or secret of that kind is deleted, the copies of the lambda's features
+ included. From its next request on, code that uses it fails with an exception that says it is off. `GET …/summary`
+ says whether the code of the lambda uses each kind (`usesDatabase`, `usesWorkspace`, `missingSecrets`), before
+ switching one off. Nothing happens if it is off already.
+ """)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or there is no such kind of data.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The kind of data, switched off and empty")]
+ public DataStoreResponse Disable([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("Which kind: `database`, `workspace` or `secrets`")] string kind)
{
var store = data.Disable(privateKey, kind);
diff --git a/src/GenHTTP.Lambda/Api/DatabaseResource.cs b/src/GenHTTP.Lambda/Api/DatabaseResource.cs
index cb41c883..c97f85e5 100644
--- a/src/GenHTTP.Lambda/Api/DatabaseResource.cs
+++ b/src/GenHTTP.Lambda/Api/DatabaseResource.cs
@@ -1,3 +1,7 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
+using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Databases;
@@ -5,61 +9,84 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The database of a lambda - a SQLite file its code reaches with
-/// Database.GetConnection() - as its owner reads it: the tables, and
-/// what is in them.
-///
///
-/// Reading only: what is in the database is written by the lambda. It is
-/// data, so every version reads the same, deploying and rolling back leave it
-/// alone, and a feature works on a copy of it. It is off until it is switched
-/// on (PUT /lambdas/{privateKey}/data/database), which makes it, empty;
-/// switching it off deletes it.
+/// Reading only: what is in the database is written by the lambda, and a
+/// change to the records is a change to the app.
///
+[Tag(ApiDocumentation.Database)]
+[Summary("The database of a lambda, read as its owner reads it: its tables, and the records in them.")]
+[Remarks("""
+ Every lambda may have a SQLite database, which its code reaches with `Database.GetConnection()`. Its schema is made
+ by migrations the lambda ships in `resources/migrations/`, applied as it starts; its records are written by the
+ lambda. This API only reads it - there is no way to change a record from outside the lambda.
+
+ It is data: every version reads the same database, deploying and rolling back leave it alone, and a feature works on
+ a copy of it, read below `…/features/{feature}/database`. It is off until it is switched on
+ (`PUT /lambdas/{privateKey}/data/database`), which makes it empty. Switching it off deletes it.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class DatabaseResource(IDatabaseService databases)
{
#region The lambda's
- ///
- /// Whether the lambda has a database, how full it is, and every table and
- /// view in it with its columns and how many rows it holds.
- ///
[ResourceMethod("lambdas/:privateKey/database")]
- public async ValueTask Get(string privateKey)
+ [Summary("Lists the tables of the database")]
+ [Remarks("""
+ And whether the lambda has a database at all, how much room it takes, and whether its code connects to it. Tables
+ SQLite keeps for itself are left out; the one Evolve keeps of the migrations it applied is marked (`migrations`).
+ While the database is switched off, the answer says so and lists nothing.
+ """)]
+ [return: Summary("The database and its tables")]
+ public async ValueTask Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> Describe(await databases.GetAsync(privateKey));
- ///
- /// A page of the rows of one table or view, newest first unless a column
- /// to sort by is named.
- ///
- /// The name of the table, encoded
- /// How many rows to skip; none, left out
- /// How many rows to answer with, up to 200; 50, left out
- /// A column to sort by; left out, the order the rows were written in
- /// Whether that order is reversed, which it is unless this says otherwise
[ResourceMethod("lambdas/:privateKey/database/tables/:table")]
- public async ValueTask Rows(string privateKey, string table, string? order, int offset = 0, int limit = 50, bool descending = true)
+ [Summary("Reads rows of a table")]
+ [Remarks("""
+ The newest rows first, unless a column to sort by is named. Each row is a list of values in the order of the
+ columns. A text longer than 2000 characters is cut and sent as `{ "text": …, "length": … }`, bytes as
+ `{ "blob": length }`, and an integer beyond what JavaScript holds exactly as text.
+ """)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or its database has no such table.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.BadRequest, "The table has no column of the name to sort by.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The database is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("A page of rows")]
+ public async ValueTask Rows([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("The name of the table or view, encoded as one segment")] string table,
+ [Summary("A column to sort by; left out, the order the rows were written in")] string? order,
+ [Summary("How many rows to skip")] int offset = 0,
+ [Summary("How many rows to answer with, 1 to 200")] int limit = 50,
+ [Summary("Whether the order is reversed - newest, or highest, first - which it is unless this is `false`")] bool descending = true)
=> Describe(await databases.ReadAsync(privateKey, table, offset, limit, order, descending));
#endregion
#region A feature's copy
- ///
- /// A feature's copy of the database, which its preview reads and writes.
- ///
[ResourceMethod("lambdas/:privateKey/features/:feature/database")]
- public async ValueTask GetOfFeature(string privateKey, string feature)
+ [Summary("Lists the tables of a feature's database")]
+ [Remarks("The copy its preview reads and writes, taken when the feature was started or its data last refreshed. Its migrations may have changed its schema.")]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoFeature, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature's copy of the database and its tables")]
+ public async ValueTask GetOfFeature([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
=> Describe(await databases.GetAsync(privateKey, feature));
- ///
- /// A page of the rows of one table of a feature's copy.
- ///
[ResourceMethod("lambdas/:privateKey/features/:feature/database/tables/:table")]
- public async ValueTask RowsOfFeature(string privateKey, string feature, string table, string? order, int offset = 0,
- int limit = 50, bool descending = true)
+ [Summary("Reads rows of a feature's database")]
+ [Remarks("As for the lambda's own database.")]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, it has no such feature, or the copy has no such table.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.BadRequest, "The table has no column of the name to sort by.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The lambda's database is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("A page of rows")]
+ public async ValueTask RowsOfFeature([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("The name of the table or view, encoded as one segment")] string table,
+ [Summary("A column to sort by; left out, the order the rows were written in")] string? order,
+ [Summary("How many rows to skip")] int offset = 0,
+ [Summary("How many rows to answer with, 1 to 200")] int limit = 50,
+ [Summary("Whether the order is reversed, which it is unless this is `false`")] bool descending = true)
=> Describe(await databases.ReadAsync(privateKey, table, offset, limit, order, descending, feature));
#endregion
diff --git a/src/GenHTTP.Lambda/Api/DemoResource.cs b/src/GenHTTP.Lambda/Api/DemoResource.cs
index 838c4c33..eae3bd51 100644
--- a/src/GenHTTP.Lambda/Api/DemoResource.cs
+++ b/src/GenHTTP.Lambda/Api/DemoResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+
+using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Hosting;
using GenHTTP.Lambda.Services.Meta;
@@ -6,9 +9,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The lambdas the installation keeps online for people and agents to read.
-///
///
/// Only the listing lives here. A demo is read like any other lambda - its
/// editor key is announced with it - so its versions, files and logs are the
@@ -16,13 +16,22 @@ namespace GenHTTP.Lambda.Api;
/// on its own lambda a minute later. Everything that would change it is
/// refused by its tier.
///
+[Tag(ApiDocumentation.Demos)]
+[Summary("Finished lambdas the installation keeps online, to be read and started from.")]
+[Remarks("""
+ Each demo shows how something is done the GenHTTP way - a REST API with a database, sign-up and log-in, a multiplayer
+ game over websockets, uploads, live updates - with its documentation and tests. Its editor key is public, so it is
+ read with the same calls as any lambda (`GET /lambdas/{privateKey}/versions/1`, its logs, its database), and every
+ change is refused with `403`. A lambda of your own starts as a copy of one with `POST /lambdas` and its `id` as
+ `template`.
+ """)]
public sealed class DemoResource(IMetaService meta, ILambdaAddresses addresses)
{
- ///
- /// Every demo, with what reading it teaches and the key to read it with.
- ///
[ResourceMethod]
+ [Summary("Lists the demos")]
+ [Remarks("Public. A few seconds after the server started, a demo may not be online yet (`live`).")]
+ [return: Summary("Every demo")]
public IReadOnlyList Get()
{
var demos = new List();
diff --git a/src/GenHTTP.Lambda/Api/DeploymentResource.cs b/src/GenHTTP.Lambda/Api/DeploymentResource.cs
index bf20e68b..c9c389c6 100644
--- a/src/GenHTTP.Lambda/Api/DeploymentResource.cs
+++ b/src/GenHTTP.Lambda/Api/DeploymentResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -12,38 +13,53 @@
namespace GenHTTP.Lambda.Api;
-///
-/// Whether a lambda is online, and putting it on or taking it off.
-///
///
/// A deployment is a process rather than a thing to be edited, so the two
/// ways of changing it are named for what they do instead of being a put and
/// a delete on something that does not quite exist. There is only ever one,
/// hence the singular.
///
+[Tag(ApiDocumentation.Deployment)]
+[Summary("Whether a lambda is online, putting a version online, and taking it offline.")]
+[Remarks("""
+ At most one version of a lambda is online at a time, and it serves every request to the lambda's address.
+ Deploying compiles the version and switches over to it once it compiles; until then, and if it does not, whatever
+ was online stays online. Deploying an older version is how a lambda is rolled back. Neither deploying nor rolling
+ back touches the data.
+
+ A free lambda that nobody visits or edits for a while is taken offline by the platform (`deployedUntil` says when),
+ and can be deployed again at any time.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class DeploymentResource(IMetaService meta, ILogger logger)
{
- ///
- /// What is online, and for how long.
- ///
[ResourceMethod("lambdas/:privateKey/deployment")]
- public DeploymentResponse Get(string privateKey)
+ [Summary("Reads what is online")]
+ [Remarks("Which version, since when, and until when it stays online unless it is used.")]
+ [return: Summary("What is online")]
+ public DeploymentResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var lambda = meta.Require(privateKey);
return new DeploymentResponse(lambda.ActiveVersion != null, lambda.ActiveVersion, lambda.DeployedAt, lambda.DeployedUntil);
}
- ///
- /// Builds a version and makes it the one that is served.
- ///
- ///
- /// Code that does not compile is answered with 422 and what the compiler
- /// said, and whatever was online before stays online.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/deployment/start")]
- public async ValueTask> Start(string privateKey, DeploymentRequest? request)
+ [Summary("Puts a version online")]
+ [Remarks("""
+ Compiles the version and, once it compiles, serves it from the next request on, in place of whatever was online.
+ The code that runs once as the lambda starts - outside the handler it returns, migrating its database, say - runs
+ now, and what it prints is in the lambda's log. The body is optional: without one, or without a `version`, the
+ newest version goes online.
+ """)]
+ [Response(ResponseStatus.Ok, "The version is online. `diagnostics` may hold warnings of the compiler.")]
+ [Response(ResponseStatus.UnprocessableEntity, ApiDocumentation.Diagnostics, Type = typeof(DeploymentOutcomeResponse))]
+ [Response(ResponseStatus.BadRequest, "The lambda has no version yet.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no such version - it never existed, or it was not kept.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> Start([Summary(ApiDocumentation.PrivateKey)] string privateKey, DeploymentRequest? request)
{
var result = await meta.DeployAsync(privateKey, request?.Version, VersionOrigins.Api);
@@ -54,11 +70,16 @@ public async ValueTask> Start(string privateKe
return new Result(payload).Status(result.Success ? ResponseStatus.Ok : ResponseStatus.UnprocessableEntity);
}
- ///
- /// Takes the lambda off the air, keeping its code.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/deployment/stop")]
- public LambdaResponse Stop(string privateKey)
+ [Summary("Takes the lambda offline")]
+ [Remarks("""
+ From the next request on, its address answers that nothing is online. Its versions and its data are kept, and
+ any version can be deployed again. Nothing happens if it is offline already. The previews of its features are
+ not affected.
+ """)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The lambda, offline")]
+ public LambdaResponse Stop([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var lambda = meta.Undeploy(privateKey, ActivationEndings.Stopped);
@@ -67,16 +88,15 @@ public LambdaResponse Stop(string privateKey)
return LambdaDescription.Of(lambda);
}
- ///
- /// Every stretch of time the lambda was online, newest first.
- ///
- ///
- /// Which version, who put it up, how long it stayed and what ended it -
- /// the next deployment, the owner, the sweep that takes down what nobody
- /// uses, or the operator. The newest entry has no end while it is live.
- ///
[ResourceMethod("lambdas/:privateKey/deployment/history")]
- public List History(string privateKey)
+ [Summary("Lists when the lambda was online")]
+ [Remarks("""
+ Which version, who put it online, how long it stayed and what ended it: the next deployment, its owner, the
+ platform because nobody used it, or the operator. The newest entry has no end while it is online. The last 200
+ are kept.
+ """)]
+ [return: Summary("The stretches, newest first")]
+ public List History([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var now = DateTime.UtcNow;
diff --git a/src/GenHTTP.Lambda/Api/FeatureResource.cs b/src/GenHTTP.Lambda/Api/FeatureResource.cs
index 4119dc85..b6cf71c6 100644
--- a/src/GenHTTP.Lambda/Api/FeatureResource.cs
+++ b/src/GenHTTP.Lambda/Api/FeatureResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -20,9 +21,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// Features: changes being worked on beside a lambda, without touching it.
-///
///
/// Versions never change once they are stored. A feature is where a change is
/// made instead: it branches off a version with a copy of its files and a copy
@@ -38,29 +36,58 @@ namespace GenHTTP.Lambda.Api;
/// the feature is left to whoever works on it; once they are in, moving the
/// feature's base to that version with a patch allows the merge.
///
+[Tag(ApiDocumentation.Features)]
+[Summary("Changes worked on beside a lambda, tried at an address of their own, and merged into it once they work.")]
+[Remarks("""
+ A feature starts as a copy of a version - the newest, unless another is named - and of the lambda's data: its
+ database, workspace and secrets as they are at that moment. It is saved in place as often as needed; unlike a
+ version, it keeps no history of its saves. Its **preview** answers at `previewPath` on this site, against its own
+ copy of the data, while the lambda goes on serving its visitors as before.
+
+ **Merging** makes the feature's files the next version of the lambda and deletes the feature, its preview and its
+ copy of the data. What the preview wrote into its copy - records, files - is not carried over: the lambda's own data
+ is never touched by a feature.
+
+ Only a feature based on the newest version can be merged, so a merge cannot undo a version saved after the feature
+ started. Where one was, its changes are brought into the feature's files first, then the feature's `base` is moved
+ to it (`PATCH`), and then it merges. The platform does not merge files itself.
+
+ A free lambda's preview goes offline, like the lambda, when nobody changes the feature for a while.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoFeature, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class FeatureResource(IFeatureService features, IMetaService meta, IDataService data, ILogBook book, LambdaOptions options, ILimitsService limits,
ILogger logger)
{
#region The features
- ///
- /// Lists the features of a lambda, the most recently changed first.
- ///
[ResourceMethod("lambdas/:privateKey/features")]
- public List List(string privateKey)
+ [Summary("Lists the features")]
+ [Remarks("The most recently changed first, without their files.")]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The features, the most recently changed first")]
+ public List List([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> [.. (features.List(privateKey)).Select(Describe)];
- ///
- /// Starts a feature from a version - the newest unless another is named.
- ///
- ///
- /// The feature gets a copy of the version's files and a copy of the
- /// lambda's workspace as it is now. Nothing is online until its preview is
- /// started, and the lambda itself is not touched at all.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/features")]
- public async ValueTask> Create(string privateKey, CreateFeatureRequest request)
+ [Summary("Starts a feature")]
+ [Remarks("""
+ The feature starts from the version named as `base`, or the newest. It gets a copy of the version's files, and a
+ copy of the lambda's database, workspace and secrets as they are now - which may take a moment for a lot of data.
+ Its preview is not online until it is started, or a save asks for it (`?deploy=true`). The lambda itself is not
+ touched.
+
+ It is given a `key`, which every call about it takes, and a `branch`, which is where it is in the lambda's git
+ repository. Only so many features may be open at once, as the tier of the lambda allows.
+ """)]
+ [Response(ResponseStatus.Created, "The feature was started.")]
+ [Response(ResponseStatus.BadRequest, "The name is missing, too long (80 characters at most) or more than one line, or the lambda has no version yet.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The lambda has as many features open as its tier allows. Merge or delete one first.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no such version to start from.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> Create([Summary(ApiDocumentation.PrivateKey)] string privateKey, CreateFeatureRequest request)
{
var created = await features.CreateAsync(privateKey, new FeatureDraft(request.Name, request.Specification, request.Base, VersionOrigins.Api));
@@ -69,28 +96,34 @@ public async ValueTask> Create(string privateKey, Create
return new Result(Describe(created)).Status(ResponseStatus.Created);
}
- ///
- /// Reads a feature with its files.
- ///
- /// Only the files below this folder - docs/ for its documentation, tests/ for its tests - rather than every one
[ResourceMethod("lambdas/:privateKey/features/:feature")]
- public FeatureContentResponse Get(string privateKey, string feature, string? folder)
+ [Summary("Reads a feature with its files")]
+ [Remarks("Its `revision` is what a later `PUT …/files` passes to be refused, rather than replace a save made since.")]
+ [return: Summary("The feature with its files")]
+ public FeatureContentResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Only the files whose name starts with this - `docs/` for its documentation, `tests/` for its tests")] string? folder)
{
var found = features.Get(privateKey, feature);
return new FeatureContentResponse(Describe(found.Feature), VersionResource.Below(LambdaSource.Parse(found.Code), folder));
}
- ///
- /// Renames a feature, changes what is said about it, or moves its base.
- ///
- ///
- /// Move the base only once the changes of that version are in the
- /// feature's files: merging replaces the lambda's files with the feature's,
- /// and nothing checks that they are.
- ///
[ResourceMethod(Method.Patch, "lambdas/:privateKey/features/:feature")]
- public FeatureResponse Update(string privateKey, string feature, UpdateFeatureRequest request)
+ [Summary("Changes a feature, or moves its base")]
+ [Remarks("""
+ Its name, its specification or its change. What the request leaves out stays as it is; a `specification` or `change`
+ sent empty is cleared. The files are not
+ touched.
+
+ Move the `base` only once the changes of that version are in the feature's files. Merging replaces the lambda's
+ files with the feature's, and nothing checks that those changes are in.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The name is empty, too long or more than one line.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, it has no such feature, or there is no version to move the base to.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature afterwards")]
+ public FeatureResponse Update([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ UpdateFeatureRequest request)
{
var updated = features.Update(privateKey, feature, new FeatureUpdate(request.Name, request.Specification, request.Change, request.Base));
@@ -99,11 +132,11 @@ public FeatureResponse Update(string privateKey, string feature, UpdateFeatureRe
return Describe(updated);
}
- ///
- /// Deletes a feature, its preview and its copy of the data.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/features/:feature")]
- public void Delete(string privateKey, string feature)
+ [Summary("Deletes a feature")]
+ [Remarks("With its preview and its copy of the data. Its files are gone for good unless they were merged. The lambda is not touched.")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
{
var name = features.Delete(privateKey, feature);
@@ -114,21 +147,38 @@ public void Delete(string privateKey, string feature)
#region Its files
- ///
- /// Saves every file of a feature, replacing the ones it has.
- ///
- /// Whether to put its preview online as well
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/files")]
- public async ValueTask PutFiles(string privateKey, string feature, bool? deploy, FeatureFilesRequest request)
+ [Summary("Saves every file of a feature")]
+ [Remarks("""
+ `files` is the whole feature, `lambda.cs` first, held to the same rules as a version - including what a version may
+ come to, since the feature becomes one. A feature keeps no history of its saves: pass the `revision` it was read
+ at, and a save made in the meantime (by an agent, or in another window) is refused rather than silently replaced.
+ """)]
+ [Response(ResponseStatus.BadRequest, "A file cannot be part of a lambda, or the files are larger than a version may be. Nothing was saved.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The feature was saved again since `revision`. Read it again and make the change on top of what it holds now.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature afterwards, and what came of its preview where that was asked for")]
+ public async ValueTask PutFiles([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Whether to put its preview online with what was saved")] bool? deploy,
+ FeatureFilesRequest request)
=> await SaveAsync(privateKey, feature, request.Files, deploy, request.Specification, request.Change, request.Revision);
- ///
- /// Changes some files of a feature: adds or replaces files, removes them,
- /// or replaces text within them. Files that are not named stay as they are.
- ///
- /// Whether to put its preview online as well
[ResourceMethod(Method.Post, "lambdas/:privateKey/features/:feature/changes")]
- public async ValueTask Change(string privateKey, string feature, bool? deploy, VersionChangeRequest request)
+ [Summary("Changes some files of a feature")]
+ [Remarks("""
+ Files that are not named stay as they are. As for a version: `remove` takes files out, `files` adds or replaces
+ them, and `edits` replaces text within them, each `find` occurring exactly once. The changes are made on top of
+ what the feature holds as the request arrives.
+ """)]
+ [Response(ResponseStatus.BadRequest, "Nothing to change, a file to remove or edit that does not exist, a `find` that occurs never or more than once, or a result that cannot be a lambda. Nothing was saved.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The feature was saved by somebody else while this change was being made. Try again.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature afterwards, and what came of its preview where that was asked for")]
+ public async ValueTask Change([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Whether to put its preview online with what was saved")] bool? deploy,
+ VersionChangeRequest request)
{
var read = features.Get(privateKey, feature);
@@ -138,13 +188,14 @@ public async ValueTask Change(string privateKey, string fe
return await SaveAsync(privateKey, feature, files, deploy, request.Specification, request.Change, read.Feature.Revision);
}
- ///
- /// Downloads the files of a feature as a zip archive - laid out as a
- /// clone with ?layout=project, as for a version.
- ///
- /// lambda (the default) or project
[ResourceMethod("lambdas/:privateKey/features/:feature/zip")]
- public IResponse GetArchive(string privateKey, string feature, string? layout, IRequest request)
+ [Summary("Downloads a feature as a zip archive")]
+ [Remarks("As for a version: the files as the lambda names them, or with `?layout=project` as a clone has them - to change them locally and put them back with `PUT …/zip`.")]
+ [Response(ResponseStatus.Ok, "The files, as a zip archive", ContentType = ApiDocumentation.Zip)]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, named after the start of the feature's key: `feature-3f9a1c2e.zip`")]
+ [Response(ResponseStatus.BadRequest, "There is no such layout.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public IResponse GetArchive([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.Layout)] string? layout, IRequest request)
{
var laid = VersionResource.LayoutOf(layout);
@@ -160,17 +211,21 @@ public IResponse GetArchive(string privateKey, string feature, string? layout, I
.Build();
}
- ///
- /// Saves the files of a zip archive as the feature's, replacing the ones
- /// it has - every file, as for a version.
- ///
- /// Whether to put its preview online as well
- /// What the user wants from it and why; left out, the one it has is kept
- /// What it changes, in a line; left out, the one it has is kept
- /// lambda (the default) or project
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/zip")]
- public async ValueTask PutArchive(string privateKey, string feature, bool? deploy, string? specification, string? change,
- string? layout, Stream body)
+ [Summary("Saves a zip archive as the feature's files")]
+ [Remarks("""
+ The archive replaces every file of the feature, and is read exactly as one saved as a version is
+ (`POST …/versions/zip`): a folder around everything removed, `.gitignore` files followed, counted as it is sent.
+ """)]
+ [RequestBody("A zip archive of every file of the feature", ContentType = ApiDocumentation.Zip)]
+ [Response(ResponseStatus.BadRequest, "The body is not a zip archive, it is larger than a version may be, it has no `lambda.cs`, or a file in it cannot be part of a lambda.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature afterwards, and what came of its preview where that was asked for")]
+ public async ValueTask PutArchive([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Whether to put its preview online with what was saved")] bool? deploy,
+ [Summary("What the user wants from the feature and why; left out, the one it has is kept")] string? specification,
+ [Summary("What it changes, in a line; left out, the one it has is kept")] string? change,
+ [Summary(ApiDocumentation.Layout)] string? layout, Stream body)
{
var laid = VersionResource.LayoutOf(layout);
@@ -190,16 +245,19 @@ public async ValueTask PutArchive(string privateKey, strin
#region Its preview
- ///
- /// Puts the feature's files online at its own address, /features/{feature}/,
- /// against its own copy of the data. The lambda itself is not touched.
- ///
- ///
- /// Code that does not compile is answered with 422 and what the compiler
- /// said, and whatever the preview served before goes on being served.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/features/:feature/preview/start")]
- public async ValueTask> Start(string privateKey, string feature)
+ [Summary("Puts the preview of a feature online")]
+ [Remarks("""
+ Compiles the feature's files and serves them at its `previewPath`, `/features/{feature}/` on this site, against its
+ own copy of the data. Nobody is sent there; it is reached by its address only. The lambda is not touched.
+
+ Links inside a lambda's pages should be relative: a feature answers below `/features/{feature}/`, where a link that
+ starts with a slash leaves it.
+ """)]
+ [Response(ResponseStatus.Ok, "The preview is online.")]
+ [Response(ResponseStatus.UnprocessableEntity, "The code does not compile. `diagnostics` holds what the compiler said; whatever the preview served before, it still serves.",
+ Type = typeof(FeaturePreviewResponse))]
+ public async ValueTask> Start([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
{
var deployment = await features.DeployAsync(privateKey, feature);
@@ -210,11 +268,11 @@ public async ValueTask> Start(string privateKey,
return new Result(outcome).Status(outcome.Success ? ResponseStatus.Ok : ResponseStatus.UnprocessableEntity);
}
- ///
- /// Takes the preview of a feature offline.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/features/:feature/preview/stop")]
- public FeatureResponse Stop(string privateKey, string feature)
+ [Summary("Takes the preview of a feature offline")]
+ [Remarks("The feature, its files and its copy of the data are kept. Nothing happens if the preview is offline already.")]
+ [return: Summary("The feature afterwards")]
+ public FeatureResponse Stop([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
{
var stopped = features.Undeploy(privateKey, feature);
@@ -223,15 +281,17 @@ public FeatureResponse Stop(string privateKey, string feature)
return Describe(stopped);
}
- ///
- /// What the preview of a feature has been doing, oldest first - kept apart
- /// from what the lambda's own visitors caused.
- ///
- /// The last sequence already seen. Omitted, only the tail comes back.
- /// The lowest level worth returning: debug, info, warn or error
- /// At most this many lines, up to 2000
[ResourceMethod("lambdas/:privateKey/features/:feature/logs")]
- public OwnerLogResponse Logs(string privateKey, string feature, long? since, string? level, int? limit)
+ [Summary("Reads the log of a feature's preview")]
+ [Remarks("""
+ Its requests, what its code printed, and what the server said while serving it - kept apart from the lambda's own
+ log, and read the same way (`GET /lambdas/{privateKey}/logs`): oldest first, followed with `since`.
+ """)]
+ [return: Summary("The lines, and where to carry on from")]
+ public OwnerLogResponse Logs([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("The `cursor` of the previous answer: only what came after it. Left out, the last 500 lines.")] long? since,
+ [Summary("The lowest level to answer with: `debug`, `info` (the default), `warn` or `error`")] string? level,
+ [Summary("At most this many lines, up to 2000; by default 500, or 1000 with `since`")] int? limit)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, false);
@@ -247,24 +307,26 @@ public OwnerLogResponse Logs(string privateKey, string feature, long? since, str
#region Its data
- ///
- /// Every kind of data there is, as the feature's copy of it is.
- ///
- ///
- /// Which kinds there are is the lambda's to decide; a feature has a copy
- /// of whatever the lambda has, taken when the feature was started. Its
- /// workspace is under /lambdas/{privateKey}/features/{feature}/workspace.
- ///
[ResourceMethod("lambdas/:privateKey/features/:feature/data")]
- public List Data(string privateKey, string feature)
+ [Summary("Lists a feature's copy of the data")]
+ [Remarks("""
+ Which kinds are switched on is the lambda's to decide; the feature has a copy of what the lambda had when the
+ feature was started or its data was last refreshed. What is in the copies is under `…/features/{feature}/database`,
+ `…/workspace` and `…/secrets`.
+ """)]
+ [return: Summary("Every kind of data, as the feature's copy of it is")]
+ public List Data([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
=> [.. (data.List(privateKey, feature)).Select(DataResource.Describe)];
- ///
- /// Replaces the feature's copy of the data with a fresh copy of the
- /// lambda's, and builds its preview again on its next request.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/features/:feature/data/refresh")]
- public async ValueTask Refresh(string privateKey, string feature)
+ [Summary("Copies the lambda's data into a feature again")]
+ [Remarks("""
+ The database, workspace and secrets of the feature are thrown away and copied from the lambda again, as they are
+ now. What the preview wrote into its copy is lost. The preview starts again on its next request, so it reads the
+ fresh copy rather than what it held in memory.
+ """)]
+ [return: Summary("The feature afterwards")]
+ public async ValueTask Refresh([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
{
var refreshed = await features.RefreshDataAsync(privateKey, feature);
@@ -277,19 +339,26 @@ public async ValueTask Refresh(string privateKey, string featur
#region Merging
- ///
- /// Makes the feature's files the next version of the lambda, and deletes
- /// the feature - its preview and its copy of the data with it.
- ///
- ///
- /// Answered with 409 while the feature is not based on the newest version,
- /// and with 422 and what the compiler said when its code does not compile;
- /// in both cases the feature stays as it is. The lambda's own data is not
- /// touched. With deploy the new version is put online as well,
- /// which is answered in the deployment it carries.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/features/:feature/merge")]
- public async ValueTask> Merge(string privateKey, string feature, MergeFeatureRequest? request)
+ [Summary("Merges a feature as the next version")]
+ [Remarks("""
+ The feature has to be based on the newest version, and its code has to compile - a version is only ever made of
+ code that compiles. Then its files are saved as the next version, with the feature's specification and change
+ unless the request says others, and the feature is deleted with its preview and its copy of the data. The lambda's
+ own data is not touched, so what the preview wrote is not carried over.
+
+ With `deploy`, the new version is put online as well; `deployment` says how that went. The version is made either
+ way. The body is optional.
+ """)]
+ [Response(ResponseStatus.Created, "The feature was merged as `version`, and is gone.")]
+ [Response(ResponseStatus.UnprocessableEntity, "The code of the feature does not compile. `diagnostics` holds what the compiler said; the feature is kept as it was, to be fixed.",
+ Type = typeof(FeatureMergeResponse))]
+ [Response(ResponseStatus.Conflict, "The feature is not based on the newest version - the message says which versions to bring in - or it was saved while it was being checked. It is kept as it was.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.BadRequest, "The feature holds exactly what the version it is based on holds, so there is nothing to merge.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> Merge([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ MergeFeatureRequest? request)
{
var note = new VersionNote(request?.Specification, request?.Change, VersionOrigins.Api);
diff --git a/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs b/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs
index 4a099209..b79c32f9 100644
--- a/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs
+++ b/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -20,76 +21,87 @@ namespace GenHTTP.Lambda.Api;
///
/// Answered exactly like the lambda's workspace below /lambdas/{privateKey}/files,
/// under the same quota, with the path of a file sent as a single segment with
-/// its slashes encoded.
+/// its slashes encoded. Listed under the tag of the workspace, which
+/// describes.
///
+[Tag(ApiDocumentation.Workspace)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoFeature, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class FeatureWorkspaceResource(IFeatureService features, IWorkspaceService workspace, IMetaService meta, ILogger logger)
{
- ///
- /// Lists every file and folder of the feature's copy of the workspace.
- ///
[ResourceMethod("lambdas/:privateKey/features/:feature/workspace")]
- public WorkspaceListing List(string privateKey, string feature)
+ [Summary("Lists the files of a feature's workspace")]
+ [Remarks("What the feature's preview reads and writes. The lambda's own workspace is not touched by it.")]
+ [return: Summary("The files and folders of the feature's copy")]
+ public WorkspaceListing List([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, false);
return workspace.List(lambdaId, featureId);
}
- ///
- /// Reads one file, base64 encoded, up to 32 MB.
- ///
- /// The path of the file within the workspace
[ResourceMethod("lambdas/:privateKey/features/:feature/workspace/:path")]
- public async ValueTask Get(string privateKey, string feature, string path)
+ [Summary("Reads a file of a feature's workspace")]
+ [Remarks("Up to 32 MB; a larger file is read as it is from `…/{path}/content`.")]
+ [Response(ResponseStatus.BadRequest, "The file is larger than 32 MB, or the path is empty or leads outside of the workspace.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file, base64 encoded")]
+ public async ValueTask Get([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.WorkspacePath)] string path)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, false);
return await WorkspaceFiles.ReadAsync(workspace, lambdaId, featureId, path, $"/api/v1/lambdas/{{privateKey}}/features/{feature}/workspace");
}
- ///
- /// Reads one file as it is, streamed from the disk, however large.
- ///
- /// The path of the file within the workspace
[ResourceMethod("lambdas/:privateKey/features/:feature/workspace/:path/content")]
- public IResponse GetContent(string privateKey, string feature, string path, IRequest request)
+ [Summary("Downloads a file of a feature's workspace")]
+ [Response(ResponseStatus.Ok, "The bytes of the file", ContentType = "application/octet-stream")]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, with the name of the file")]
+ public IResponse GetContent([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.WorkspacePath)] string path, IRequest request)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, false);
return WorkspaceFiles.Send(workspace, lambdaId, featureId, path, request);
}
- ///
- /// Writes a file from the body as it is, streamed to the disk however large.
- ///
- /// The path of the file within the workspace
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/workspace/:path/content")]
- public async ValueTask PutContent(string privateKey, string feature, string path, IRequest request)
+ [Summary("Uploads a file into a feature's workspace")]
+ [Remarks("As for the lambda's own workspace: of any size, replacing what is there only once the upload is complete.")]
+ [RequestBody("The bytes of the file", ContentType = "application/octet-stream")]
+ [Response(ResponseStatus.BadRequest, "The file does not fit into the data allowance of the lambda, the upload ended early, or the path cannot be used. Nothing was written.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The lambda's workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file as it was written")]
+ public async ValueTask PutContent([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.WorkspacePath)] string path, IRequest request)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, true);
return Written(privateKey, feature, await WorkspaceFiles.ReceiveAsync(workspace, lambdaId, featureId, path, request));
}
- ///
- /// Writes a file, base64 encoded, replacing it if it is already there.
- ///
- /// The path of the file within the workspace
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/workspace/:path")]
- public async ValueTask Put(string privateKey, string feature, string path, FileRequest request)
+ [Summary("Writes a file into a feature's workspace")]
+ [Response(ResponseStatus.BadRequest, "The content is not base64, it does not fit into the data allowance of the lambda, or the path cannot be used. Nothing was written.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The lambda's workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file as it was written")]
+ public async ValueTask Put([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.WorkspacePath)] string path, FileRequest request)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, true);
return Written(privateKey, feature, await workspace.WriteEncodedAsync(lambdaId, path, request.Content, featureId));
}
- ///
- /// Removes a file, or a folder and everything in it.
- ///
- /// The path within the workspace
[ResourceMethod(Method.Delete, "lambdas/:privateKey/features/:feature/workspace/:path")]
- public void Delete(string privateKey, string feature, string path)
+ [Summary("Deletes from a feature's workspace")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.BadRequest, "The path is empty or leads outside of the workspace.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary(ApiDocumentation.WorkspacePath)] string path)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, true);
@@ -99,13 +111,14 @@ public void Delete(string privateKey, string feature, string path)
features.NameOf(privateKey, feature), meta.PublicKeyOf(privateKey));
}
- ///
- /// Makes a folder, so files can be put into it.
- ///
- /// Where it goes within the workspace
- /// The workspace afterwards
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/folders/:path")]
- public WorkspaceListing PutFolder(string privateKey, string feature, string path)
+ [Summary("Makes a folder in a feature's workspace")]
+ [Response(ResponseStatus.BadRequest, "A file has that path, there is no room for another folder, or the path cannot be used.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The lambda's workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature's copy of the workspace afterwards")]
+ public WorkspaceListing PutFolder([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Where the folder goes within the workspace, sent as one segment with its slashes encoded")] string path)
{
var (lambdaId, featureId) = features.Require(privateKey, feature, true);
diff --git a/src/GenHTTP.Lambda/Api/FileResource.cs b/src/GenHTTP.Lambda/Api/FileResource.cs
index cb8f751e..9d774907 100644
--- a/src/GenHTTP.Lambda/Api/FileResource.cs
+++ b/src/GenHTTP.Lambda/Api/FileResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -12,90 +13,112 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The files a lambda keeps in its private directory - its workspace, which is
-/// the lambda's data rather than a part of any version.
-///
///
/// A file is addressed by its path within the workspace, sent as a single
/// segment with the slashes encoded (logs%2Ftoday.txt), so that a path
/// that happens to end in something this API routes on cannot be mistaken
-/// for it. A feature's copy of the workspace is reached the same way below
-/// /lambdas/{privateKey}/features/{feature}/workspace.
+/// for it. The description of the tag is here; the feature's copy, in
+/// , shares it.
///
+[Tag(ApiDocumentation.Workspace)]
+[Summary("The files a lambda keeps: its workspace, which its code reads and writes while it runs.")]
+[Remarks("""
+ The workspace is data, not part of a version: every version reads and writes the same files, and deploying or rolling
+ back leaves them alone. It is where uploads, generated files and large files that are no part of the program - a
+ model, a dataset, media - go. It is switched on unless its owner switched it off (`DELETE …/data/workspace`, which
+ deletes every file); while it is off, nothing can be written.
+
+ A file is addressed by its path within the workspace, sent as **one** segment with its slashes encoded:
+ `GET /lambdas/{privateKey}/files/logs%2Ftoday.txt`. Files are written as they are with `PUT …/content`, of any size, or
+ base64 encoded in JSON. Either way, the room it takes counts towards the data allowance of the lambda - workspace and
+ database together - in blocks of 4 KB, a folder taking one block.
+
+ A feature has a copy of the workspace, reached the same way below `/lambdas/{privateKey}/features/{feature}/`.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class FileResource(IMetaService meta, IWorkspaceService workspace, ILogger logger)
{
- ///
- /// Lists every file and folder.
- ///
[ResourceMethod("lambdas/:privateKey/files")]
- public WorkspaceListing List(string privateKey)
+ [Summary("Lists the files of the workspace")]
+ [return: Summary("The files and folders of the workspace")]
+ public WorkspaceListing List([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> workspace.List(meta.RequireId(privateKey));
- ///
- /// Reads one file.
- ///
- /// The path of the file within the workspace
- ///
- /// The content travels base64 encoded in a JSON document like everything
- /// else this API answers, so a workspace holding an image or an archive
- /// reads the same way as one holding text. Up to 32 MB; anything larger
- /// is read from …/content.
- ///
[ResourceMethod("lambdas/:privateKey/files/:path")]
- public async ValueTask Get(string privateKey, string path)
+ [Summary("Reads a file, base64 encoded")]
+ [Remarks("""
+ The content travels base64 encoded in JSON, so an image or an archive reads the same way as text. Up to 32 MB; a
+ larger file is refused here and read as it is from `…/{path}/content`.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The file is larger than 32 MB, or the path is empty or leads outside of the workspace.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or there is no such file.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file, base64 encoded")]
+ public async ValueTask Get([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.WorkspacePath)] string path)
=> await WorkspaceFiles.ReadAsync(workspace, meta.RequireId(privateKey), null, path, "/api/v1/lambdas/{privateKey}/files");
- ///
- /// Reads one file as it is, streamed from the disk, however large.
- ///
- /// The path of the file within the workspace
[ResourceMethod("lambdas/:privateKey/files/:path/content")]
- public IResponse GetContent(string privateKey, string path, IRequest request)
+ [Summary("Downloads a file as it is, however large")]
+ [Remarks("Always sent as bytes to save, whatever the name of the file says, so a page somebody uploaded never runs on this site.")]
+ [Response(ResponseStatus.Ok, "The bytes of the file", ContentType = "application/octet-stream")]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, with the name of the file")]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or there is no such file.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public IResponse GetContent([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.WorkspacePath)] string path, IRequest request)
=> WorkspaceFiles.Send(workspace, meta.RequireId(privateKey), null, path, request);
- ///
- /// Writes a file from the body as it is, replacing it if it is already
- /// there.
- ///
- /// The path of the file within the workspace
- ///
- /// Written to the disk as it arrives, so a file of any size costs the
- /// server a buffer rather than itself - the way in for a model, a dataset
- /// or anything else too large for a JSON document.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/files/:path/content")]
- public async ValueTask PutContent(string privateKey, string path, IRequest request)
+ [Summary("Uploads a file as it is")]
+ [Remarks("""
+ The body is the file. It replaces a file of the same path, and the folders it goes into are made. It is written as
+ it arrives and only takes the place of the old one once it is complete: an upload that is refused for want of room,
+ or that ends before the `Content-Length` it announced, leaves what was there as it was. The way in for anything too
+ large for JSON.
+ """)]
+ [RequestBody("The bytes of the file", ContentType = "application/octet-stream")]
+ [Response(ResponseStatus.BadRequest, "The file does not fit into the data allowance of the lambda, the upload ended early, or the path cannot be used. Nothing was written.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file as it was written")]
+ public async ValueTask PutContent([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.WorkspacePath)] string path,
+ IRequest request)
=> Written(privateKey, await WorkspaceFiles.ReceiveAsync(workspace, meta.RequireEditable(privateKey), null, path, request));
- ///
- /// Writes a file, replacing it if it is already there.
- ///
- /// The path of the file within the workspace
[ResourceMethod(Method.Put, "lambdas/:privateKey/files/:path")]
- public async ValueTask Put(string privateKey, string path, FileRequest request)
+ [Summary("Writes a file sent base64 encoded")]
+ [Remarks("Replaces a file of the same path, and makes the folders it goes into. For a large file, `PUT …/{path}/content` sends it as it is.")]
+ [Response(ResponseStatus.BadRequest, "The content is not base64, it does not fit into the data allowance of the lambda, or the path cannot be used. Nothing was written.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file as it was written")]
+ public async ValueTask Put([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.WorkspacePath)] string path,
+ FileRequest request)
=> Written(privateKey, await workspace.WriteEncodedAsync(meta.RequireEditable(privateKey), path, request.Content));
- ///
- /// Removes a file, or a folder and everything in it.
- ///
- /// The path within the workspace
[ResourceMethod(Method.Delete, "lambdas/:privateKey/files/:path")]
- public void Delete(string privateKey, string path)
+ [Summary("Deletes a file, or a folder and everything in it")]
+ [Remarks("Nothing happens for a path where there is nothing.")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.BadRequest, "The path is empty or leads outside of the workspace.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.WorkspacePath)] string path)
{
workspace.Delete(meta.RequireEditable(privateKey), path);
logger.LogInformation("Deleted workspace path {Path} of lambda {Lambda}", path, meta.PublicKeyOf(privateKey));
}
- ///
- /// Makes a folder, so files can be put into it.
- ///
- /// Where it goes within the workspace
- /// The workspace afterwards
[ResourceMethod(Method.Put, "lambdas/:privateKey/folders/:path")]
- public WorkspaceListing PutFolder(string privateKey, string path)
+ [Summary("Makes a folder, with the folders it goes into")]
+ [Remarks("Writing a file makes its folders on its own; this is for a folder that is empty for now. Nothing happens if it is there already.")]
+ [Response(ResponseStatus.BadRequest, "A file has that path, there is no room for another folder, or the path cannot be used.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "The workspace is switched off.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The workspace afterwards")]
+ public WorkspaceListing PutFolder([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary("Where the folder goes within the workspace, sent as one segment with its slashes encoded")] string path)
{
var listing = WorkspaceFiles.CreateFolder(workspace, meta.RequireEditable(privateKey), null, path);
diff --git a/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs b/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs
index 29ba3f65..2169efa3 100644
--- a/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs
+++ b/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs
@@ -32,7 +32,19 @@ public static class AdminAuthentication
///
public const string Role = "admin";
- private const string Header = "X-Admin-Token";
+ ///
+ /// The header the token is presented in.
+ ///
+ public const string Header = "X-Admin-Token";
+
+ // how the API description says it, for each resource behind the token -
+ // the framework's API key concern does not describe itself
+
+ public const string HeaderDescription = "The administration token of the installation (`LAMBDA_ADMIN_TOKEN`)";
+
+ public const string MissingDescription = "The token is missing.";
+
+ public const string WrongDescription = "The token is wrong.";
///
/// The concern that lets a request with the configured token through.
diff --git a/src/GenHTTP.Lambda/Api/Infrastructure/ApiDocumentation.cs b/src/GenHTTP.Lambda/Api/Infrastructure/ApiDocumentation.cs
new file mode 100644
index 00000000..47a55b97
--- /dev/null
+++ b/src/GenHTTP.Lambda/Api/Infrastructure/ApiDocumentation.cs
@@ -0,0 +1,179 @@
+using System.Reflection;
+
+using GenHTTP.Modules.OpenApi;
+using GenHTTP.Modules.OpenApi.Handler;
+
+using NSwag;
+
+namespace GenHTTP.Lambda.Api.Infrastructure;
+
+///
+/// The OpenAPI document of the API, as whoever calls it reads it - in Scalar
+/// at /api/v1/scalar/, or as /api/v1/openapi.json.
+///
+///
+/// The operations, their parameters, responses and models document
+/// themselves with the framework's documentation attributes, beside the code
+/// they describe. What belongs to no single operation is here: the title, the
+/// introduction everybody reads first (Resources/Api/Introduction.md),
+/// and the tags in the order and the groups a reader goes through them.
+///
+/// A tag names a concept somebody calls the API about, never the class that
+/// answers it - several resources share one where they serve the same thing
+/// (the showcase, the open sources, the workspace). Every tag in use is in a
+/// group, which ApiDocumentationTests checks.
+///
+public static class ApiDocumentation
+{
+
+ #region Tags
+
+ public const string Platform = "Platform";
+
+ public const string Keys = "Keys";
+
+ public const string Demos = "Demos";
+
+ public const string Lambdas = "Lambdas";
+
+ public const string Versions = "Versions";
+
+ public const string Deployment = "Deployment";
+
+ public const string Features = "Features";
+
+ public const string Code = "Code analysis";
+
+ public const string Data = "Data";
+
+ public const string Database = "Database";
+
+ public const string Workspace = "Workspace";
+
+ public const string Secrets = "Secrets";
+
+ public const string Monitoring = "Monitoring";
+
+ public const string Analytics = "Analytics";
+
+ public const string Domain = "Domain";
+
+ public const string Showcase = "Showcase";
+
+ public const string OpenSource = "Open source";
+
+ public const string Builds = "Builds";
+
+ public const string Changes = "Changes";
+
+ public const string Administration = "Administration";
+
+ public const string Telemetry = "Telemetry";
+
+ public const string ServerLog = "Server log";
+
+ ///
+ /// The tags in the order they are read, in the groups Scalar shows them in.
+ ///
+ internal static readonly IReadOnlyList<(string Group, string[] Tags)> Groups =
+ [
+ ("Getting started", [Platform, Keys, Demos, Lambdas]),
+ ("The program", [Versions, Deployment, Features, Code]),
+ ("What it keeps", [Data, Database, Workspace, Secrets]),
+ ("How it is doing", [Monitoring, Analytics]),
+ ("Sharing it", [Domain, Showcase, OpenSource]),
+ ("The build agent", [Builds, Changes]),
+ ("Operating the installation", [Administration, Telemetry, ServerLog])
+ ];
+
+ #endregion
+
+ #region Shared descriptions
+
+ // said by many operations, so they are said the same way by all of them
+
+ public const string Json = "application/json";
+
+ public const string Zip = "application/zip";
+
+ public const string PrivateKey = "The editor key of the lambda. Holding it is what allows the call.";
+
+ public const string FeatureKey = "The key of the feature, as `key` in the answer that started it";
+
+ public const string VersionNumber = "The number of the version, counting from 1";
+
+ public const string WorkspacePath = "The path within the workspace, sent as one segment with its slashes encoded: `logs%2Ftoday.txt`";
+
+ public const string Deploy = "Whether to put the new version online as well. The version is saved either way; whether it went online is in `deployment`.";
+
+ public const string Layout = "How the files are laid out: `lambda` (the default) names them as the lambda does, `project` as a clone of its git repository has them - "
+ + "the snippet as `Project.cs`, the other C# files named the .NET way, the platform's own files left out";
+
+ public const string Specification = "What the user wants from this version and why, in their words where possible. Kept with the version, cut past 4000 characters.";
+
+ public const string Change = "What this version changes, in one line for the owner. Kept with the version, cut past 500 characters.";
+
+ public const string NoLambda = "No lambda has this editor key. A key that never existed and the key of a deleted lambda are answered alike.";
+
+ public const string NoFeature = "No lambda has this editor key, or the lambda has no feature with this key: it never existed, or it was merged or deleted.";
+
+ public const string Demo = "The lambda is a demo, which anybody may read and nobody may change. Create a lambda of your own from it instead: "
+ + "`POST /lambdas` with the demo's id as `template`.";
+
+ public const string Diagnostics = "The code does not compile. `diagnostics` holds what the compiler said, with the file, line and column of each problem; "
+ + "nothing was put online, and whatever was online before still is.";
+
+ public const string Deleted = "Done. There is no body.";
+
+ #endregion
+
+ #region Functionality
+
+ ///
+ /// The concern that serves the OpenAPI document of what it is added to.
+ ///
+ public static OpenApiConcernBuilder Create()
+ => ApiDescription.Create()
+ .Title("GenHTTP Lambda API")
+ .Version("v1")
+ .PostProcessor((_, document) => Complete(document));
+
+ private static void Complete(OpenApiDocument document)
+ {
+ document.Info.Description = Introduction.Value;
+
+ // the tags in reading order, then whatever no group names - which the
+ // tests refuse, but a reader should still find it rather than lose it
+ var order = Groups.SelectMany(g => g.Tags).ToList();
+
+ var sorted = document.Tags.OrderBy(t => order.IndexOf(t.Name) is var index and >= 0 ? index : int.MaxValue).ToList();
+
+ document.Tags.Clear();
+
+ foreach (var tag in sorted)
+ {
+ document.Tags.Add(tag);
+ }
+
+ document.ExtensionData ??= new Dictionary();
+
+ document.ExtensionData["x-tagGroups"] = Groups.Select(g => new Dictionary
+ {
+ ["name"] = g.Group,
+ ["tags"] = g.Tags
+ }).ToList();
+ }
+
+ private static readonly Lazy Introduction = new(() =>
+ {
+ using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("GenHTTP.Lambda.Resources.Api.Introduction.md")
+ ?? throw new InvalidOperationException("The introduction of the API is missing from the assembly.");
+
+ using var reader = new StreamReader(stream);
+
+ return reader.ReadToEnd().ReplaceLineEndings("\n");
+ });
+
+ #endregion
+
+}
diff --git a/src/GenHTTP.Lambda/Api/Infrastructure/ApiLayout.cs b/src/GenHTTP.Lambda/Api/Infrastructure/ApiLayout.cs
index d4784927..dbbc643b 100644
--- a/src/GenHTTP.Lambda/Api/Infrastructure/ApiLayout.cs
+++ b/src/GenHTTP.Lambda/Api/Infrastructure/ApiLayout.cs
@@ -6,7 +6,6 @@
using GenHTTP.Modules.DependencyInjection.Infrastructure;
using GenHTTP.Modules.ErrorHandling;
using GenHTTP.Modules.Layouting;
-using GenHTTP.Modules.OpenApi;
using GenHTTP.Modules.Reflection;
using GenHTTP.Modules.Webservices.Provider;
@@ -47,7 +46,7 @@ public static IHandlerBuilder Create(LambdaOptions options)
.Add(Resource(mode))
.Add(Resource(mode))
.AddScalar(title: "GenHTTP Lambda API")
- .AddOpenApi()
+ .Add(ApiDocumentation.Create())
.Add(ErrorHandler.From(new ApiErrorMapper()));
// what only the operator may see, behind the token - and not there at
diff --git a/src/GenHTTP.Lambda/Api/KeyResource.cs b/src/GenHTTP.Lambda/Api/KeyResource.cs
index 27a28aac..d5ae98f4 100644
--- a/src/GenHTTP.Lambda/Api/KeyResource.cs
+++ b/src/GenHTTP.Lambda/Api/KeyResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+
+using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Meta;
@@ -5,9 +8,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// Public keys, and what anybody may know about one.
-///
///
/// One answer for both questions a public key gets asked: whether it could be
/// claimed for a new lambda, and whether something is answering there. They
@@ -16,15 +16,20 @@ namespace GenHTTP.Lambda.Api;
/// Lives beside /lambdas rather than below it, where every single
/// segment is read as an editor key.
///
+[Tag(ApiDocumentation.Keys)]
+[Summary("Public keys, and what anybody may know about one: whether it is free, and whether something answers there.")]
+[Remarks("Public. A key names the address of a lambda, so what can be learned here is what anybody could learn by visiting it.")]
public sealed class KeyResource(IMetaService meta)
{
- ///
- /// Whether a key is free, and if it is not, whether its lambda is online.
- ///
- /// The key, normalized the way it would be stored
[ResourceMethod(":publicKey")]
- public KeyResponse Get(string publicKey)
+ [Summary("Describes a public key")]
+ [Remarks("""
+ Never refused: a key that cannot be claimed is answered with `valid: false` and the `reason`, as a page checking a
+ key while it is typed needs. A key that is free now may be taken by the time a lambda is created with it.
+ """)]
+ [return: Summary("What anybody may know about the key")]
+ public KeyResponse Get([Summary("The key to look up, in any case - it is answered the way it would be stored")] string publicKey)
{
var status = meta.DescribeKey(publicKey);
diff --git a/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs b/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs
index f52440a1..114743b2 100644
--- a/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -13,10 +14,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The agent of this installation, working on one lambda for whoever holds
-/// its editor key: the Change section of the control center.
-///
///
/// Behind the editor key like everything else about a lambda, and refused for
/// a demo like everything else that would change one. The agent is the same
@@ -27,41 +24,57 @@ namespace GenHTTP.Lambda.Api;
/// A process rather than a thing, so it is read at agent and driven
/// with verbs, the way a deployment is.
///
+[Tag(ApiDocumentation.Changes)]
+[Summary("The build agent of the installation, changing a lambda for whoever holds its editor key.")]
+[Remarks("""
+ What the Change section of the editor does: the owner says what should be different, and the agent changes the lambda
+ in a feature - a new one, or one named - and tries it at its preview. With `deploy` (the default) it merges the feature
+ and puts the new version online once it works; without, it leaves the feature for the owner to try and merge. What is
+ online stays online until then.
+
+ One change of a lambda runs at a time. Changes count against the same daily allowance as builds, per address, which
+ starts again at midnight UTC. Demos cannot be changed. Errors that come from the agent carry `status` and `message`
+ only.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+[Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class LambdaAgentResource(IBuildService builds, IMetaService meta, IFeatureService features, ILogger logger)
{
- ///
- /// Whether a change can be asked for, how many this caller has left
- /// today, and the change under way or the last one.
- ///
- ///
- /// Polled while a change runs. The agent remembers a change for an hour
- /// after it was asked for; after that there is nothing to report, and the
- /// versions it made are in the history.
- ///
[ResourceMethod("lambdas/:privateKey/agent")]
- public async ValueTask Get(string privateKey, IRequest request)
+ [Summary("Reads how a change is getting on")]
+ [Remarks("""
+ Asked again while a change runs. The agent remembers a change for an hour after it was asked for; after that there
+ is nothing to report, and what it saved is in the history of the lambda and its features.
+ """)]
+ [return: Summary("Whether there is an agent, how many changes this caller has left today, and the change under way or the last one")]
+ public async ValueTask Get([Summary(ApiDocumentation.PrivateKey)] string privateKey, IRequest request)
{
var lambda = meta.RequireEditable(privateKey);
return await builds.StateAsync(lambda, request.Client.Address);
}
- ///
- /// Asks the agent to change the lambda.
- ///
///
- /// One change of a lambda at a time: a second is refused with 409 while
- /// the first is queued or running, since two agents writing the same files
- /// would each undo the other. Counted against the same daily allowance as
- /// the build page.
- ///
- /// The agent works in a feature: a new one, or the one named - which is
- /// looked up here, so a feature that does not exist is refused before
- /// anything is spent on it.
+ /// One change of a lambda at a time, since two agents writing the same
+ /// files would each undo the other. The feature is looked up here, so one
+ /// that does not exist is refused before anything is spent on it.
///
[ResourceMethod(Method.Post, "lambdas/:privateKey/agent/start")]
- public async ValueTask> Start(string privateKey, ChangeRequest body, IRequest request)
+ [Summary("Asks the agent to change a lambda")]
+ [Remarks("""
+ The change is queued and runs in the background; `GET …/agent` follows it. The prompt is logged in full, so the
+ operator knows what people ask for, and cut past 2000 characters. A request that is not about changing the
+ application is declined, and the result says why.
+ """)]
+ [Response(ResponseStatus.Accepted, "The change was taken on.")]
+ [Response(ResponseStatus.BadRequest, "The prompt says too little, or there is no such model.")]
+ [Response(ResponseStatus.Conflict, "A change of this lambda is under way already. Wait for it, or stop it first.")]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, it has no such feature to go on with, or this installation takes no changes from here.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.TooManyRequests, "This address asked for as many builds and changes today as one may.")]
+ [Response(ResponseStatus.ServiceUnavailable, "The agent is not answering. Nothing was counted.")]
+ public async ValueTask> Start([Summary(ApiDocumentation.PrivateKey)] string privateKey, ChangeRequest body, IRequest request)
{
var lambda = meta.RequireEditable(privateKey);
@@ -78,12 +91,11 @@ public async ValueTask> Start(string privateKey, ChangeReques
return new Result(state).Status(ResponseStatus.Accepted);
}
- ///
- /// Stops the change under way. What it saved so far stays saved - in its
- /// feature, or as a version - and whatever is online stays online.
- ///
[ResourceMethod(Method.Post, "lambdas/:privateKey/agent/stop")]
- public async ValueTask Stop(string privateKey, IRequest request)
+ [Summary("Stops the change under way")]
+ [Remarks("What it saved so far stays saved - in its feature, or as a version - and whatever is online stays online. A change that is stopped is not counted back.")]
+ [return: Summary("The state afterwards")]
+ public async ValueTask Stop([Summary(ApiDocumentation.PrivateKey)] string privateKey, IRequest request)
{
var lambda = meta.RequireEditable(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs b/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs
index 65d20f38..3181082a 100644
--- a/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs
@@ -1,3 +1,7 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
+using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Analytics;
@@ -8,37 +12,49 @@
namespace GenHTTP.Lambda.Api;
-///
-/// How a lambda's visitors reach it, for its owner.
-///
///
/// Behind the editor key like everything else about a lambda: what is
/// counted of its visitors is the owner's to see, and nobody else's.
-/// Counting is the owner's choice and never starts on its own.
///
+[Tag(ApiDocumentation.Analytics)]
+[Summary("Who visits a lambda, counted by day: visitors, page views and requests, and where they came from.")]
+[Remarks("""
+ Counting is the owner's choice and never starts on its own. Once switched on, the visits of a lambda are counted by
+ day in UTC: visitors, page views and requests, and the pages, referring sites, countries, devices, browsers, systems
+ and crawlers behind them. They are written down every few minutes, so the last minutes are not in an answer yet.
+ A year is kept of every lambda; the tier decides how much of it is shown - a free lambda the last seven days, a
+ premium one all of it, as the operator set it.
+
+ A **visitor** is a person who opened a page, counted once a day. A **page view** is a page opened, by people or by
+ programs - `botViews` are the programs'. Requests count everything. Crawlers are told from people by a maintained
+ list, and the big operators' (Google, Bing, OpenAI, Apple, Perplexity) are confirmed by the addresses they publish.
+
+ Nothing that names a visitor is kept: counts only, the referrer as its host, and visitors told apart for a day by a
+ hash that is never written down. No cookie, and nothing added to the lambda. The previews of features and the demos
+ are not counted.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class LambdaAnalyticsResource(IAnalyticsService analytics, ILogger logger)
{
- ///
- /// Whether the lambda's visits are counted, and what was counted over the
- /// last days: by day, page, referrer, country, device, browser, system and
- /// crawler.
- ///
- ///
- /// Counted by day in UTC, and written down every few minutes, so the
- /// visits of the last minutes are not in it yet. A year is kept of every
- /// lambda; a free one shows its owner the last seven days of it, a premium
- /// one all of it.
- ///
- /// How many days, today included - left out, as many as its tier shows, which is also the most
[ResourceMethod("lambdas/:privateKey/analytics")]
- public AnalyticsResponse Get(string privateKey, int? days = null) => AnalyticsResponse.Of(analytics.Get(privateKey, days));
+ [Summary("Reads what was counted")]
+ [Remarks("""
+ By day, and broken down by page, referring site, country, device, browser, system and crawler, with the same number
+ of days before for comparison where the tier shows those as well. While counting is switched off, the answer says
+ so and has no figures.
+ """)]
+ [return: Summary("Whether visits are counted, and what was counted")]
+ public AnalyticsResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("How many days, today included; left out, as many as the tier shows, which is also the most")] int? days = null)
+ => AnalyticsResponse.Of(analytics.Get(privateKey, days));
- ///
- /// Starts counting the lambda's visits, from the next request on.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/analytics")]
- public AnalyticsResponse Put(string privateKey)
+ [Summary("Switches counting on")]
+ [Remarks("Counting starts with the next request. Nothing happens if it is on already.")]
+ [Response(ResponseStatus.Forbidden, "The lambda is a demo, whose visitors are not counted.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("Analytics, switched on")]
+ public AnalyticsResponse Put([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var enabled = analytics.Enable(privateKey);
@@ -47,11 +63,12 @@ public AnalyticsResponse Put(string privateKey)
return AnalyticsResponse.Of(enabled);
}
- ///
- /// Stops counting the lambda's visits, and deletes what was counted.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/analytics")]
- public void Delete(string privateKey)
+ [Summary("Switches counting off and deletes it")]
+ [Remarks("**This cannot be undone**: the whole year that was kept is deleted. Switched on again, counting starts from nothing.")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.Forbidden, "The lambda is a demo, whose visitors are not counted.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var publicKey = analytics.Disable(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs b/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs
index bae26b9a..75525a28 100644
--- a/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs
@@ -1,6 +1,9 @@
using System.Net;
using System.Net.Sockets;
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Meta;
@@ -13,16 +16,24 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The domain a lambda answers at instead of its subdomain of the hosting
-/// domain, for its owner.
-///
///
/// Behind the editor key like everything else about a lambda. Whether a lambda
/// may have one at all is decided by its tier, which only an administrator
/// sets - so the owner can read here why the domain is not served, but can
/// only change the domain itself.
///
+[Tag(ApiDocumentation.Domain)]
+[Summary("A domain of a lambda's own, which it answers at instead of its subdomain of the hosting domain.")]
+[Remarks("""
+ Part of the premium tier, which only the operator assigns. While a lambda answers at its domain, its subdomain of the
+ hosting domain sends its visitors on to the domain with a permanent redirect, and `address` names the domain.
+
+ The domain is entered as it should answer. `www.` in front of a domain is sent on to the domain itself, unless the
+ domain was entered with `www.` - then that is where it answers, and the name without is not sent there. The domain has
+ to point to this installation (in the DNS) and needs a certificate, which the operator of the installation takes
+ care of; the answer says what the domain resolves to, as the server sees it, to check the first.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class LambdaDomainResource(IMetaService meta, ILogger logger)
{
@@ -32,25 +43,29 @@ public sealed class LambdaDomainResource(IMetaService meta, ILogger
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(3);
- ///
- /// The domain of the lambda, whether it is served, and what it resolves to.
- ///
[ResourceMethod("lambdas/:privateKey/domain")]
- public async ValueTask Get(string privateKey)
+ [Summary("Reads the domain of a lambda")]
+ [Remarks("The name servers are asked as the request is answered, for up to three seconds, and their answers may be cached for as long as the records say.")]
+ [return: Summary("The domain, and what it resolves to")]
+ public async ValueTask Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> await DescribeAsync(meta.Require(privateKey));
- ///
- /// Sets the domain the lambda answers at.
- ///
- ///
- /// Only in the premium tier. Takes effect at once: the next request
- /// addressed to the domain reaches the lambda, one addressed to the
- /// previous domain no longer does, and the lambda's subdomain of the
- /// hosting domain sends its visitors on to the domain from then on - so
- /// the domain should point here, with a certificate for it, first.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/domain")]
- public async ValueTask Put(string privateKey, DomainChangeRequest request)
+ [Summary("Sets the domain a lambda answers at")]
+ [Remarks("""
+ Takes effect at once: the next request addressed to the domain reaches the lambda, one addressed to its previous
+ domain no longer does, and its subdomain of the hosting domain sends its visitors on to the domain from then on -
+ so the domain should point here, with a certificate, first.
+
+ The domain is lower cased, an international one written as it is in the DNS, and a pasted address is forgiven its
+ scheme and path. It needs an ending (`example.com`, not `example`), and may not be an IP address or a name of the
+ platform. Sending none removes it, as `DELETE` does.
+ """)]
+ [Response(ResponseStatus.BadRequest, "That is not a domain that can be used. The message says why.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, "The lambda is not in the premium tier - or it is a demo.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "Another lambda answers at that domain.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The domain afterwards, and what it resolves to")]
+ public async ValueTask Put([Summary(ApiDocumentation.PrivateKey)] string privateKey, DomainChangeRequest request)
{
var lambda = meta.ChangeDomain(privateKey, request.Domain);
@@ -59,12 +74,12 @@ public async ValueTask Put(string privateKey, DomainChangeReques
return await DescribeAsync(lambda);
}
- ///
- /// Stops the lambda answering at its domain. Its subdomain of the hosting
- /// domain answers again instead of sending visitors there.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/domain")]
- public async ValueTask Delete(string privateKey)
+ [Summary("Removes the domain of a lambda")]
+ [Remarks("From the next request on it no longer answers at the domain, and its subdomain of the hosting domain answers again instead of sending visitors there. Any tier may remove a domain.")]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The lambda, without a domain")]
+ public async ValueTask Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var lambda = meta.ChangeDomain(privateKey, null);
diff --git a/src/GenHTTP.Lambda/Api/LambdaResource.cs b/src/GenHTTP.Lambda/Api/LambdaResource.cs
index 34925a28..d0d8f872 100644
--- a/src/GenHTTP.Lambda/Api/LambdaResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Infrastructure;
using GenHTTP.Api.Protocol;
@@ -21,9 +22,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The lambdas themselves: made, read, renamed, removed.
-///
///
/// A lambda is addressed by its editor key rather than its public one, because
/// the key in the path is what authorizes the call - whoever has it may edit
@@ -33,14 +31,39 @@ namespace GenHTTP.Lambda.Api;
/// What is done here is logged by the lambda's public key, never by the key in
/// the path - so are the other resources below it.
///
+[Tag(ApiDocumentation.Lambdas)]
+[Summary("The lambdas themselves: created, read, moved to another key, exported and deleted.")]
+[Remarks("""
+ A lambda is addressed by its **editor key** (`privateKey`), which is also what authorizes every call below
+ `/lambdas/{privateKey}`. Its versions, deployment, data and everything else about it are below the same path.
+
+ Where a lambda answers: `publicUrl` is its address below the hosting domain, named after its public key.
+ `address` is where to link to it - its own domain while it answers at one (premium only), `publicUrl` otherwise.
+ """)]
public sealed class LambdaResource(IMetaService meta, ISecretService secrets, ISourceService sources, IDatabaseService databases, LambdaOptions options,
ILogger logger)
{
- ///
- /// Creates a new lambda and returns its keys.
- ///
[ResourceMethod(Method.Post, "lambdas")]
+ [Summary("Creates a lambda")]
+ [Remarks("""
+ The new lambda is in the free tier and has one version: a copy of the `template`, or a snippet that says hello if
+ none is given. Nothing is online until a version is deployed (`POST /lambdas/{privateKey}/deployment/start`, or a
+ save with `?deploy=true`). A copy of a demo that keeps records starts with an empty database of its own, switched on;
+ the demo's records are not copied.
+
+ The answer holds the editor key (`privateKey`). It is handed out this once and cannot be recovered: whoever loses it
+ loses the lambda, and whoever finds it may change it.
+
+ A `publicKey` that is asked for is trimmed and lower cased, and must be 3 to 40 characters of `a-z`, `0-9` and `-`,
+ neither starting nor ending with a dash. It may not start with `demo-` or `xn--`, and a few names are reserved for
+ the platform (`www`, `api`, `admin`, `mail` and the like). `GET /keys/{publicKey}` says whether a key can be claimed
+ before trying. Left out, a random key of eight characters is chosen.
+ """)]
+ [Response(ResponseStatus.Created, "The lambda was created. Keep `privateKey`: it is not handed out again.")]
+ [Response(ResponseStatus.BadRequest, "The terms were not accepted, or the key, the template or the view cannot be used. The message says which.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "Another lambda has the public key that was asked for.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public Result Create(CreateLambdaRequest request)
{
if (!request.AcceptedTerms)
@@ -55,28 +78,35 @@ public Result Create(CreateLambdaRequest request)
return new Result(LambdaDescription.Of(lambda)).Status(ResponseStatus.Created);
}
- ///
- /// Reads a lambda by the private key of its editor.
- ///
[ResourceMethod("lambdas/:privateKey")]
- public LambdaResponse Get(string privateKey)
+ [Summary("Reads a lambda")]
+ [Remarks("Its keys and tier, its newest version and the one online, until when it stays online and is kept, and where it answers.")]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The lambda")]
+ public LambdaResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> LambdaDescription.Of(meta.Require(privateKey));
- ///
- /// Changes a lambda. What the request leaves out stays as it is.
- ///
///
- /// Moving the public key moves the address the lambda answers at, and the
- /// old one is free for anybody to claim afterwards. The view is only what
- /// the editor opens in for somebody who has not picked one: whoever has
- /// keeps theirs, since that is remembered in their browser.
- ///
/// The view is read before anything changes, so a request that names one
/// that does not exist changes nothing at all rather than half of what it
/// asked for.
///
[ResourceMethod(Method.Patch, "lambdas/:privateKey")]
- public LambdaResponse Update(string privateKey, UpdateLambdaRequest request)
+ [Summary("Changes the public key or the view of a lambda")]
+ [Remarks("""
+ What the request leaves out stays as it is.
+
+ A new `publicKey` follows the rules of `POST /lambdas` and moves the address the lambda answers at, at once: nothing
+ answers at the old address any more, and anybody may claim it. A domain of the lambda's own is not affected.
+
+ The `view` is only what the editor opens in for somebody who has not picked a view of their own.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The key or the view cannot be used. Nothing was changed.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "Another lambda has the public key that was asked for.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The lambda afterwards")]
+ public LambdaResponse Update([Summary(ApiDocumentation.PrivateKey)] string privateKey, UpdateLambdaRequest request)
{
var view = EditorViews.Parse(request.View);
@@ -101,11 +131,17 @@ public LambdaResponse Update(string privateKey, UpdateLambdaRequest request)
return LambdaDescription.Of(lambda);
}
- ///
- /// Removes the lambda for good.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey")]
- public void Delete(string privateKey)
+ [Summary("Deletes a lambda for good")]
+ [Remarks("""
+ Takes it offline and removes everything kept for it: every version, every feature with its preview, its database,
+ workspace and secrets, its showcase entry and published source, and what was counted of its visitors. This cannot be
+ undone, and its public key is free for anybody to claim afterwards. `GET …/export` takes a copy first.
+ """)]
+ [Response(ResponseStatus.NoContent, "The lambda and everything kept for it are gone.")]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var publicKey = meta.PublicKeyOf(privateKey);
@@ -114,22 +150,28 @@ public void Delete(string privateKey)
logger.LogInformation("Deleted lambda {Lambda}", publicKey);
}
- ///
- /// The newest version of the lambda as a .NET 10 project, with a
- /// Dockerfile, that can be opened and run without this platform - and its
- /// database, where it has one.
- ///
///
/// A way out. Whatever somebody writes here runs on a machine they do not
/// own, for as long as it is left running; being able to take it away is
/// the difference between a place to build something and a place it is
- /// stuck. The records a lambda keeps are part of what is taken: its
- /// database comes along as database/database.db, which the project opens
- /// as it opened it here. The workspace and the values of the secrets stay.
+ /// stuck.
///
- /// The lambda being taken away
[ResourceMethod("lambdas/:privateKey/export")]
- public async ValueTask Export(string privateKey, IRequest request)
+ [Summary("Exports the lambda as a .NET project")]
+ [Remarks("""
+ A zip of the **newest** version - not necessarily the one online - as a .NET 10 project on GenHTTP: a `Program.cs`
+ that hosts the snippet, stand-ins for what the platform provides in `Platform/`, and a `Dockerfile`. `dotnet run` or
+ `docker build` it, and it serves what the lambda serves.
+
+ The database comes along as `database/database.db`, a copy taken while the request is answered, which the project
+ opens as the lambda opened it here. The workspace does not come along, and neither do the values of the secrets: the
+ project reads them from environment variables of the same names, which `Program.cs` lists. A published source
+ carries its `LICENSE`. Exporting changes nothing about the lambda.
+ """)]
+ [Response(ResponseStatus.Ok, "The project, as a zip archive", ContentType = ApiDocumentation.Zip)]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, named after the public key: `quiz.zip`")]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no version to export.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask Export([Summary(ApiDocumentation.PrivateKey)] string privateKey, IRequest request)
{
var lambda = meta.Require(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs b/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs
index fbb47ec5..c96b68a6 100644
--- a/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Hosting;
using GenHTTP.Lambda.Services.Meta;
@@ -12,39 +15,48 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The showcase entry of one lambda, for its owner.
-///
///
/// Behind the editor key like everything else about a lambda, so only
/// whoever may change the lambda may put it in front of everybody. Apart from
/// the versions and the deployment on purpose: presenting a lambda is
-/// something done once it works, not a step of making it work.
+/// something done once it works, not a step of making it work. The tag is
+/// described here; the public listing, , shares it.
///
+[Tag(ApiDocumentation.Showcase)]
+[Summary("The showcase: lambdas their owners chose to show, for anybody to browse - and putting one there.")]
+[Remarks("""
+ Showcasing is the owner's choice and never happens on its own. An entry has a title, a description and a picture,
+ and is listed (`GET /showcases`) only while its lambda is online. The listing is public and names lambdas by their
+ public key and address only - never an editor key.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class LambdaShowcaseResource(IShowcaseService showcases, IMetaService meta, ILimitsService limits, ILambdaAddresses addresses,
ILogger logger)
{
- ///
- /// The entry of the lambda, if it has one, and what may go into one.
- ///
[ResourceMethod("lambdas/:privateKey/showcase")]
- public OwnShowcaseResponse Get(string privateKey)
+ [Summary("Reads the showcase entry of a lambda")]
+ [return: Summary("The entry, if the lambda has one, and the limits of an entry")]
+ public OwnShowcaseResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var showcase = showcases.Get(privateKey);
return new OwnShowcaseResponse(showcase == null ? null : ShowcaseResponse.Of(showcase, addresses), Limits(limits));
}
- ///
- /// Puts the lambda into the showcase, or changes what its entry says.
- ///
- ///
- /// A new entry needs a picture; a changed one keeps its picture unless
- /// another is sent. The entry is only listed while the lambda is online.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/showcase")]
- public ShowcaseResponse Put(string privateKey, ShowcaseRequest request)
+ [Summary("Puts a lambda into the showcase")]
+ [Remarks("""
+ Or changes what its entry says. A new entry needs a picture: a PNG, JPEG, GIF or WebP image, base64 encoded, no larger than the limits say. A
+ changed entry keeps its picture unless another is sent. The title (60 characters, one line) and the description
+ (280 characters) are required either way, and should read plainly: what it is and what a visitor can do with it.
+ The entry is listed from now on, while the lambda is online.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The title or the description is missing or too long, or the picture is missing, too large, not base64 or not an image of a kind that is taken.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The entry, as the showcase lists it")]
+ public ShowcaseResponse Put([Summary(ApiDocumentation.PrivateKey)] string privateKey, ShowcaseRequest request)
{
byte[]? image = null;
@@ -67,11 +79,12 @@ public ShowcaseResponse Put(string privateKey, ShowcaseRequest request)
return ShowcaseResponse.Of(saved, addresses);
}
- ///
- /// Takes the lambda out of the showcase.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/showcase")]
- public void Delete(string privateKey)
+ [Summary("Takes a lambda out of the showcase")]
+ [Remarks("Its entry and its picture are deleted. Nothing happens if it has none.")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
showcases.Remove(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs b/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs
index 2a9b8b1a..c5992821 100644
--- a/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs
+++ b/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Source;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -10,24 +13,30 @@
namespace GenHTTP.Lambda.Api;
-///
-/// Whether the source of one lambda is published, for its owner.
-///
///
/// Behind the editor key like everything else about a lambda, so only whoever
-/// may change the lambda may give its code away. Publishing is the owner's
-/// choice and never happens on its own: nothing is published until this is
-/// asked for, and what is published is the program - never the data.
+/// may change the lambda may give its code away. The tag is described here;
+/// the public side, , shares it.
///
+[Tag(ApiDocumentation.OpenSource)]
+[Summary("Publishing the code of a lambda, for anybody to read, clone, download and star.")]
+[Remarks("""
+ Publishing is the owner's choice and never happens on its own. What is published is the program: every version kept,
+ as the export packs it - its code, resources, documentation and tests - with a `LICENSE`. **Never the data**: no
+ database, workspace or secret, and nothing only the owner may know - not the specification of a version, the editor
+ key, traffic, logs or visitors. A version is described by its change line only.
+
+ A published source is read at `/source/{publicKey}` on this site, through `GET /sources/…`, and cloned with git from
+ `/source/{publicKey}.git`, read only. A source that is not published is not found, like a key nobody has.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class LambdaSourceResource(ISourceService sources, IMetaService meta, ILogger logger)
{
- ///
- /// Whether the source is published and under which license, and the
- /// licenses it can be published under.
- ///
[ResourceMethod("lambdas/:privateKey/source")]
- public OwnSourceResponse Get(string privateKey)
+ [Summary("Reads whether the code is published")]
+ [return: Summary("What it is published under, if it ever was, and the licenses it can be published under")]
+ public OwnSourceResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var source = sources.Get(privateKey);
@@ -35,18 +44,19 @@ public OwnSourceResponse Get(string privateKey)
[.. SourceLicenses.All.Select(LicenseResponse.Of)], SourceLicenses.Default, SourceLicenses.MaxAuthor);
}
- ///
- /// Publishes the source of the lambda - every version's code, resources,
- /// documentation and tests, never its data - or changes the license it is
- /// published under.
- ///
- ///
- /// What the request leaves out stays as it is; published for the first
- /// time without a license, it is MIT. An empty author names nobody, and
- /// the license then names "the authors of" the lambda.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/source")]
- public SourceSettingsResponse Put(string privateKey, SourceRequest request)
+ [Summary("Publishes the code of a lambda")]
+ [Remarks("""
+ Asked again, it changes the license or the author. Every version kept is published at once, and every version saved later as well. What the request leaves out stays
+ as it is; published for the first time without a license, it is MIT. The license names the `author` as the holder
+ of the copyright - or, without one, "the authors of" the lambda. Whatever somebody downloaded keeps the license it
+ came with.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The license is not one on offer, or the author is too long or more than one line.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("What it is published under now")]
+ public SourceSettingsResponse Put([Summary(ApiDocumentation.PrivateKey)] string privateKey, SourceRequest request)
{
var source = sources.Publish(privateKey, new SourceDraft(request.License, request.Author));
@@ -55,13 +65,16 @@ public SourceSettingsResponse Put(string privateKey, SourceRequest request)
return SourceSettingsResponse.Of(source);
}
- ///
- /// Takes the source down. Its stars are kept for when it is published
- /// again. Whatever somebody downloaded while it was published stays theirs
- /// under the license it came with.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/source")]
- public void Delete(string privateKey)
+ [Summary("Takes the published code of a lambda down")]
+ [Remarks("""
+ From now on it is not found at `/source/{publicKey}`, and cannot be cloned. Its stars and its license are kept for
+ when it is published again. Whatever somebody downloaded while it was published stays theirs, under the license it
+ came with. Nothing happens if it is not published.
+ """)]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var source = sources.Withdraw(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/LogResource.cs b/src/GenHTTP.Lambda/Api/LogResource.cs
index befca761..3918f8a3 100644
--- a/src/GenHTTP.Lambda/Api/LogResource.cs
+++ b/src/GenHTTP.Lambda/Api/LogResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Configuration;
@@ -16,24 +19,30 @@ namespace GenHTTP.Lambda.Api;
/// Behind the token, see : this is whatever a
/// stranger's code decided to print, which can be anything it saw.
///
+[Tag(ApiDocumentation.ServerLog)]
+[Summary("What the server and the lambdas on it have been saying.")]
+[Remarks("""
+ One ring of lines, held in memory and shared by everything on the installation: the requests, what lambdas printed,
+ and what the server said. Behind the token, since it holds whatever a stranger's code decided to print - and, where
+ the operator records them, the addresses of callers.
+ """)]
+[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)]
+[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)]
+[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)]
public sealed class LogResource(ILogBook book, IRunLog runs, LambdaOptions options)
{
#region Functionality
- ///
- /// Everything said after the given sequence, oldest first.
- ///
- ///
- /// The last sequence already seen. Omitted, only the tail comes back, so
- /// arriving does not mean reading the whole ring.
- ///
- /// Narrows it to one lambda's public key
- /// Narrows it to one caller's address
- /// The lowest level worth returning
- /// At most this many lines
[ResourceMethod]
- public LogResponse Get(long? since, string? lambda, string? level, string? client, int? limit)
+ [Summary("Reads the log, oldest first")]
+ [Remarks("To follow it, pass the `cursor` of each answer as `since` of the next. Arriving without one reads only the tail, not the whole ring.")]
+ [return: Summary("The lines, and where to carry on from")]
+ public LogResponse Get([Summary("The `cursor` of the previous answer: only what came after it. Left out, the last 1000 lines.")] long? since,
+ [Summary("Only the lines of the lambda of this public key")] string? lambda,
+ [Summary("The lowest level to answer with: `trace`, `debug`, `info` (the default), `warn`, `error` or `critical`")] string? level,
+ [Summary("Only the lines about the caller of this address")] string? client,
+ [Summary("At most this many lines; by default 1000, or 2000 with `since`")] int? limit)
{
// a reader carrying a cursor wants what is new, which is little; one
// arriving without one wants a screenful of history, and how much of a
@@ -59,15 +68,15 @@ public LogResponse Get(long? since, string? lambda, string? level, string? clien
);
}
- ///
- /// Every caller the log still holds something about, busiest first.
- ///
///
/// Its own route rather than part of the tail, because it is a pass over
/// the whole ring and the tail is asked for every second and a half.
///
[ResourceMethod("callers")]
- public IReadOnlyList GetCallers(int limit = 500)
+ [Summary("Lists the callers in the log")]
+ [Remarks("Busiest first.")]
+ [return: Summary("The callers")]
+ public IReadOnlyList GetCallers([Summary("At most this many callers")] int limit = 500)
{
return book.Callers(limit)
.Select(c => new LogCaller(c.Client, c.Place, c.Country, c.Agent, c.Lines, c.Failed, c.First, c.Last))
diff --git a/src/GenHTTP.Lambda/Api/Model/Admin.cs b/src/GenHTTP.Lambda/Api/Model/Admin.cs
index 6796ef9e..85cadcc1 100644
--- a/src/GenHTTP.Lambda/Api/Model/Admin.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Admin.cs
@@ -1,113 +1,137 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Telemetry;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// Every lambda on the installation, for the administration panel.
-///
-/// How many the search matched, which is what the pages count from
+[Summary("A page of every lambda on the installation.")]
public sealed record AdminListingResponse(
+ [Summary("The lambdas of this page")]
IReadOnlyList Lambdas,
+ [Summary("How many lambdas there are")]
int Total,
+ [Summary("How many of them are online")]
int Deployed,
+ [Summary("How many the search matched, which is what the pages count from")]
int Matched,
+ [Summary("The page this is, counting from 1")]
int Page,
+ [Summary("How many pages there are")]
int Pages
);
-///
-/// One lambda as the panel sees it: what the database knows, and what it has
-/// been doing since the server came up.
-///
///
/// This carries the editor key, so it only ever answers a request that has
-/// presented the administration token. The traffic figures are counters held
-/// in memory, so they start again with the process and are absent for a lambda
-/// nobody has called yet.
+/// presented the administration token.
///
+[Summary("A lambda as the operator sees it: what is kept about it, and what it has been doing since the server came up.")]
public sealed record AdminLambda(
+ [Summary("Its public key")]
string PublicKey,
+ [Summary("Its editor key")]
string PrivateKey,
+ [Summary("`Free`, `Premium` or `Demo`")]
string Tier,
+ [Summary("When it was created")]
DateTime Created,
+ [Summary("When something about it last changed")]
DateTime Modified,
+ [Summary("The version online; absent while none is")]
int? ActiveVersion,
+ [Summary("Its newest version")]
int? LatestVersion,
+ [Summary("How many versions are kept")]
int Versions,
+ [Summary("When it is taken offline unless it is used before then")]
DateTime? DeployedUntil,
+ [Summary("When it is deleted unless it is used before then")]
DateTime? KeptUntil,
+ [Summary("Requests since the server came up")]
long Requests,
+ [Summary("Of those, answered with a server error")]
long Failed,
+ [Summary("When the last request arrived, since the server came up")]
DateTime? LastSeen,
+ [Summary("The domain of its own, if it has one")]
string? Domain,
+ [Summary("Whether it answers at that domain")]
bool DomainServed
);
-///
-/// Everything about one lambda, for the page the panel shows it on.
-///
-/// The lambda, as its owner sees it - including the editor key
-/// What it has been doing since the server came up
-/// Its stored versions, newest first
-/// Every stretch of time it was online, newest first
-/// The tiers there are, for the panel to offer
+[Summary("Everything about one lambda, for the operator.")]
public sealed record AdminLambdaDetail(
+ [Summary("The lambda, as its owner sees it - its editor key included")]
LambdaResponse Lambda,
+ [Summary("What it has been doing since the server came up")]
LambdaTraffic Traffic,
+ [Summary("Its versions, newest first")]
IReadOnlyList Versions,
+ [Summary("Every stretch of time it was online, newest first")]
IReadOnlyList Activations,
+ [Summary("The tiers it may be moved to")]
IReadOnlyList Tiers
);
-///
-/// Moves a lambda to a tier.
-///
-public sealed record TierRequest(string Tier);
+[Summary("The tier to move a lambda to.")]
+public sealed record TierRequest(
+ [Summary("`Free` or `Premium`, in any case")]
+ string Tier
+);
-///
-/// Sets the domain of a lambda. Nothing, or an empty one, removes it.
-///
-public sealed record DomainChangeRequest(string? Domain);
+[Summary("The domain a lambda is to answer at.")]
+public sealed record DomainChangeRequest(
+ [Summary("The domain, as it should answer: `shop.example.com`, or `www.example.com` to answer there. Nothing, or an empty one, removes it.")]
+ string? Domain
+);
-///
-/// What is left of a lambda after the panel acted on it.
-///
-public sealed record LambdaOverviewResponse(string PublicKey, int? ActiveVersion, DateTime? DeployedUntil);
+[Summary("What is left of a lambda after the operator acted on it.")]
+public sealed record LambdaOverviewResponse(
+ [Summary("Its public key")]
+ string PublicKey,
+ [Summary("The version online; absent while none is")]
+ int? ActiveVersion,
+ [Summary("When it is taken offline unless it is used before then")]
+ DateTime? DeployedUntil
+);
-///
-/// What the operator can switch on or off.
-///
-/// Whether the header links to the enterprise page
-/// Whether /build offers its text box, where there is an agent
-/// Whether the Change section of the editor offers its text box, where there is an agent
-public sealed record SettingsModel(bool EnterprisePage, bool BuildBox = true, bool ChangeBox = true);
+[Summary("What the operator can switch on or off for the whole installation.")]
+public sealed record SettingsModel(
+ [Summary("Whether the header of the site links to the enterprise page")]
+ bool EnterprisePage,
+ [Summary("Whether `/build` offers its text box, where there is an agent")]
+ bool BuildBox = true,
+ [Summary("Whether the Change section of the editor offers its text box, where there is an agent")]
+ bool ChangeBox = true
+);
-///
-/// What a lambda may have and do, as the operator set it.
-///
-/// What a free lambda, or a demo, may have
-/// What a premium lambda may have; never less than a free one
-/// How long a free lambda may go without visits or edits before it is taken offline
-/// How long a free lambda may go without visits or edits before it is removed
-/// How large the picture of a showcase entry may be
-/// How many requests one client may send to the lambdas in a second
-/// How many builds and changes one address may ask the build agent for in a day
+[Summary("What a lambda may have and do, as the operator set it.")]
public sealed record LimitsModel(
+ [Summary("What a free lambda, or a demo, may have")]
TierLimitsModel Free,
+ [Summary("What a premium lambda may have; never less than a free one")]
TierLimitsModel Premium,
+ [Summary("How long a free lambda may go without a visit or an edit before it is taken offline, in hours")]
int OfflineAfterHours,
+ [Summary("How long a free lambda may go without a visit or an edit before it is deleted, in hours")]
int RemovedAfterHours,
+ [Summary("How large the picture of a showcase entry may be, in bytes")]
int ShowcaseImageBytes,
+ [Summary("How many requests one client may send to the lambdas in a second")]
int RequestsPerSecond,
+ [Summary("How many builds and changes one address may ask the build agent for in a day")]
int BuildsPerDay
);
-///
-/// What a lambda of one tier may have.
-///
-/// How large a version may be: its code and its resources together
-/// The room its data may take: its database and its workspace together
-/// How many of its versions are kept
-/// How many features it may have open at once
-/// How many days of its analytics its owner is shown, of the year that is kept
-public sealed record TierLimitsModel(long BuildBytes, long DataBytes, int Versions, int Features, int AnalyticsDays);
+[Summary("What a lambda of one tier may have.")]
+public sealed record TierLimitsModel(
+ [Summary("What a version may come to, its code and its resources together, in bytes")]
+ long BuildBytes,
+ [Summary("The room its data may take, its database and its workspace together, in bytes")]
+ long DataBytes,
+ [Summary("How many of its versions are kept")]
+ int Versions,
+ [Summary("How many features it may have open at once")]
+ int Features,
+ [Summary("How many days of its analytics its owner is shown, of the year that is kept")]
+ int AnalyticsDays
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Analytics.cs b/src/GenHTTP.Lambda/Api/Model/Analytics.cs
index 5c2585d7..af80cc4b 100644
--- a/src/GenHTTP.Lambda/Api/Model/Analytics.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Analytics.cs
@@ -1,58 +1,55 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Analytics;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// How a lambda's visitors reach it, as its owner is shown.
-///
-/// Whether its visits are counted; nothing below is there while they are not
-/// When counting was switched on
-/// Free, Premium or Demo - what decides how many days are shown
-/// How many days this answer covers, today included
-/// The most days its tier shows
-/// How many days are kept of every lambda, whatever its tier shows
-/// The first day of the answer, in UTC
-/// The last day of the answer: today, in UTC
-/// The first day anything was counted on - before where more is kept than shown
-/// The days of the answer, summed
-/// The same number of days before, summed - only where the tier shows those as well
-/// Each day of the answer, oldest first, days without visits included
-/// The paths that were answered, the most viewed first - a page opened is a view, every request a hit
-/// The sites visitors came from, by host
-/// The countries their addresses were allocated to, by ISO code; empty where not known
-/// desktop, mobile, tablet, bot or other
-/// The browsers of people - bots are not counted here
-/// The operating systems of people
-/// Whose bots came: search engines, AIs, chats showing a preview
-/// When visits were last written down; visits since then are not in this answer yet
-/// How often they are written down
-///
-/// The operators whose crawlers are confirmed by the addresses they publish - Google, Bing, OpenAI, Apple, Perplexity -
-/// as far as those could be fetched. In their names stand for confirmed visits,
-/// "unconfirmed" for claims to be theirs from elsewhere, and every other value for a kind of crawler.
-///
+[Summary("What was counted of a lambda's visitors, as its owner is shown it.")]
+[Remarks("While counting is switched off, everything from `totals` to `crawlers` is absent.")]
public sealed record AnalyticsResponse(
+ [Summary("Whether its visits are counted")]
bool Enabled,
+ [Summary("When counting was switched on")]
DateTime? Since,
+ [Summary("`Free`, `Premium` or `Demo` - which decides how many days are shown")]
string Tier,
+ [Summary("How many days this answer covers, today included")]
int Days,
+ [Summary("The most days its tier shows")]
int Window,
+ [Summary("How many days are kept of every lambda, whatever its tier shows")]
int Kept,
+ [Summary("The first day of the answer, in UTC")]
DateOnly From,
+ [Summary("The last day of the answer: today, in UTC")]
DateOnly To,
+ [Summary("The first day anything was counted on - before `from` where more is kept than shown")]
DateOnly? Earliest,
+ [Summary("The days of the answer, summed")]
AnalyticsTotalsResponse? Totals,
+ [Summary("The same number of days before, summed - only where the tier shows those as well")]
AnalyticsTotalsResponse? Previous,
+ [Summary("Each day of the answer, oldest first, days without visits included")]
IReadOnlyList? Daily,
+ [Summary("The paths that were answered, the most viewed first")]
IReadOnlyList? Pages,
+ [Summary("The sites people came from, by host")]
IReadOnlyList? Referrers,
+ [Summary("The countries their addresses are registered in, by ISO code; empty where not known")]
IReadOnlyList? Countries,
+ [Summary("`desktop`, `mobile`, `tablet`, `bot` or `other`")]
IReadOnlyList? Devices,
+ [Summary("The browsers of people - programs are not counted here")]
IReadOnlyList? Browsers,
+ [Summary("The operating systems of people")]
IReadOnlyList? Systems,
+ [Summary("Whose programs came: search engines, AIs, chats showing a preview. The names in `confirmedCrawlers` stand for visits confirmed by their addresses, `unconfirmed` for claims to be one of them from elsewhere, and every other value for a kind of crawler.")]
IReadOnlyList? Crawlers,
+ [Summary("When visits were last written down; the visits since then are not in this answer yet")]
DateTime? Updated,
+ [Summary("How often visits are written down, in seconds")]
int IntervalSeconds,
+ [Summary("The operators whose crawlers are confirmed by the addresses they publish - Google, Bing, OpenAI, Apple, Perplexity - as far as those could be fetched")]
IReadOnlyList ConfirmedCrawlers
)
{
@@ -92,28 +89,23 @@ public static AnalyticsResponse Of(LambdaAnalytics analytics)
}
-///
-/// What was counted over some days, summed.
-///
-/// Every request answered - pages, the files they load, calls of an API
-/// The requests that were somebody opening a page - people and programs alike
-///
-/// Of the views, those of programs rather than people: crawlers, and whatever names no browser. The pages people
-/// opened are the views less these.
-///
-/// Different visitors, each counted once a day - summed over days, one who came back is counted again
-/// Answered with a server error
-/// Answered with a client error, not found included
-/// What the responses carried, where that was known
-/// The mean time to answer a request
+[Summary("What was counted over some days, summed.")]
public sealed record AnalyticsTotalsResponse(
+ [Summary("Every request answered - pages, the files they load, calls of an API")]
long Hits,
+ [Summary("The requests that were somebody opening a page - people and programs alike")]
long Views,
+ [Summary("Of the views, those of programs rather than people: crawlers, and whatever names no browser. The pages people opened are the views less these.")]
long BotViews,
+ [Summary("Different people who opened a page, each counted once a day - summed over days, one who came back is counted again")]
long Visitors,
+ [Summary("Requests answered with a server error")]
long Failed,
+ [Summary("Requests answered with a client error, not found included")]
long Rejected,
+ [Summary("What the responses carried, where that was known, in bytes")]
long Bytes,
+ [Summary("The mean time to answer a request, in milliseconds")]
double AverageMillis
)
{
@@ -124,16 +116,22 @@ public static AnalyticsTotalsResponse Of(AnalyticsTotals totals)
}
-///
-/// One day, in UTC.
-///
-public sealed record AnalyticsDayResponse(DateOnly Date, AnalyticsTotalsResponse Totals);
+[Summary("What was counted on one day.")]
+public sealed record AnalyticsDayResponse(
+ [Summary("The day, in UTC")]
+ DateOnly Date,
+ [Summary("What was counted on it")]
+ AnalyticsTotalsResponse Totals
+);
-///
-/// One value of what visits are counted by.
-///
-/// The path, the host, the country code or the name; "(other)" gathers what did not fit in a day
-/// Requests it was part of
-/// Pages opened it was part of
-/// Of those, the pages programs opened rather than people
-public sealed record AnalyticsValueResponse(string Value, long Hits, long Views, long BotViews);
+[Summary("One value visits are counted by, and how often it came up.")]
+public sealed record AnalyticsValueResponse(
+ [Summary("The path, the host, the country code or the name; `(other)` gathers what did not fit into a day's list")]
+ string Value,
+ [Summary("Requests it was part of")]
+ long Hits,
+ [Summary("Pages opened it was part of")]
+ long Views,
+ [Summary("Of those, the pages programs opened rather than people")]
+ long BotViews
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Builds.cs b/src/GenHTTP.Lambda/Api/Model/Builds.cs
index a03d5e6f..d87facb9 100644
--- a/src/GenHTTP.Lambda/Api/Model/Builds.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Builds.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
///
@@ -10,28 +12,42 @@ namespace GenHTTP.Lambda.Api.Model;
/// editor, which already holds its key, or to an agent over MCP. Accepting a
/// key here made one door do two jobs and blurred what the box is for.
///
-public sealed record BuildRequest(string? Prompt, string? Model = null, string? Password = null);
+[Summary("What should be built.")]
+public sealed record BuildRequest(
+ [Summary("What to build, in the words of whoever wants it, in any language - at least three characters, cut past 2000")]
+ string? Prompt,
+ [Summary("Which of the offered models builds it: left out or `opus` for the default one, `fable` for the second, where one is offered")]
+ string? Model = null,
+ [Summary("The password for the second model")]
+ string? Password = null
+);
-///
-/// Whether this installation builds things, and how often it lets one caller.
-///
///
-/// Whether a second model is on offer. Said, but never which one: the page
-/// needs to know whether to offer the choice, not what the answer is.
+/// Said, but never which one: the page needs to know whether to offer the
+/// choice, not what the answer is.
///
-public sealed record BuildAvailability(bool Available, int PerDay, bool SecondModel);
+[Summary("Whether this installation builds things, and how often it lets one caller.")]
+public sealed record BuildAvailability(
+ [Summary("Whether builds can be asked for: there is an agent, and the operator has not switched building off")]
+ bool Available,
+ [Summary("How many builds and changes one address may ask for in a day")]
+ int PerDay,
+ [Summary("Whether a second model is on offer, behind a password")]
+ bool SecondModel
+);
-///
-/// What the Change section sends: what should be different about the lambda
-/// whose editor key the request is made with.
-///
-///
-/// Whether to merge the change and put it online once it works, rather than
-/// leave it in its feature for the owner to try first
-///
-/// The language of the control center, for the agent to fall back on
-///
-/// The feature to go on with, by its key; left out, the agent starts a new one
-///
-public sealed record ChangeRequest(string? Prompt, bool Deploy = true, string? Model = null, string? Password = null, string? Language = null,
- string? Feature = null);
+[Summary("What should be different about a lambda.")]
+public sealed record ChangeRequest(
+ [Summary("What to change, in the words of whoever wants it, in any language - at least three characters, cut past 2000")]
+ string? Prompt,
+ [Summary("Whether to merge the change and put it online once it works (the default), rather than leave it in its feature for the owner to try first")]
+ bool Deploy = true,
+ [Summary("Which of the offered models makes it: left out or `opus` for the default one, `fable` for the second, where one is offered")]
+ string? Model = null,
+ [Summary("The password for the second model")]
+ string? Password = null,
+ [Summary("The language of whoever asks, as a code like `de` or `pt-pt`, for the agent to answer in where the prompt does not tell")]
+ string? Language = null,
+ [Summary("The key of a feature to go on with; left out, the agent starts a new one")]
+ string? Feature = null
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Code.cs b/src/GenHTTP.Lambda/Api/Model/Code.cs
index 6f2be8c8..488677f5 100644
--- a/src/GenHTTP.Lambda/Api/Model/Code.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Code.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
@@ -10,39 +12,70 @@ namespace GenHTTP.Lambda.Api.Model;
/// position: the analyses that read a single file take the one named by
/// , those that read the lambda take them all.
///
-/// The files of the lambda, lambda.cs first
-/// The file the question is about, the first one if omitted
-/// The line the caret is on, counting from zero
-/// The column it is at, counting from zero
-public sealed record CodeRequest(IReadOnlyList? Files, string? File = null, int Line = 0, int Column = 0);
+[Summary("The files a question about code is asked of, and where in them to look.")]
+public sealed record CodeRequest(
+ [Summary("The files of the lambda, `lambda.cs` first, as they would be saved")]
+ IReadOnlyList? Files,
+ [Summary("The file a question about one file is about; left out, the first")]
+ string? File = null,
+ [Summary("The line of the position, counting from zero")]
+ int Line = 0,
+ [Summary("The column of the position, counting from zero")]
+ int Column = 0
+);
-///
-/// The result of a build, whether or not it went online.
-///
-public sealed record CompilationResponse(bool Success, IReadOnlyList Diagnostics);
+[Summary("Whether code compiles, and what the compiler said about it.")]
+public sealed record CompilationResponse(
+ [Summary("Whether it compiles, and would go online if it were deployed")]
+ bool Success,
+ [Summary("The errors that keep it from compiling, or the warnings of code that does")]
+ IReadOnlyList Diagnostics
+);
-///
-/// One name in a snippet and what the compiler resolved it to, in the
-/// coordinates of the code the user wrote.
-///
-public sealed record SemanticToken(int Line, int Column, int Length, string Kind);
+[Summary("A name in a file, and what the compiler resolved it to.")]
+public sealed record SemanticToken(
+ [Summary("The line it is on, counting from zero")]
+ int Line,
+ [Summary("The column it starts at, counting from zero")]
+ int Column,
+ [Summary("How many characters it spans")]
+ int Length,
+ [Summary("What it names: `type`, `interface`, `struct`, `enum`, `enumMember`, `method`, `property`, `parameter`, `local` or `namespace`")]
+ string Kind
+);
-///
-/// What every name in a snippet means.
-///
-public sealed record SemanticsResponse(IReadOnlyList Tokens);
+[Summary("What every name in a file means.")]
+public sealed record SemanticsResponse(
+ [Summary("The names, in the order they appear")]
+ IReadOnlyList Tokens
+);
-///
-/// One thing the compiler says could be written there.
-///
-public sealed record ResolvedCompletionResponse(string Label, string Kind, string Detail, string? Documentation);
+[Summary("Something that could be written at a position.")]
+public sealed record ResolvedCompletionResponse(
+ [Summary("What to write")]
+ string Label,
+ [Summary("What it is: `class`, `interface`, `struct`, `enum`, `method`, `property`, `field`, `parameter`, `variable` or `text`")]
+ string Kind,
+ [Summary("Its signature, as C# writes it")]
+ string Detail,
+ [Summary("What its documentation says about it, where it has any")]
+ string? Documentation
+);
-///
-/// Everything that could be written there.
-///
-public sealed record CompletionsResponse(IReadOnlyList Completions);
+[Summary("Everything that could be written at a position.")]
+public sealed record CompletionsResponse(
+ [Summary("The suggestions")]
+ IReadOnlyList Completions
+);
-///
-/// Where to go, or nothing if there is nowhere in the lambda to go.
-///
-public sealed record DefinitionResponse(string? File, int Line, int Column, int Length);
+[Summary("Where a name was declared, or nothing where that is in none of the files.")]
+public sealed record DefinitionResponse(
+ [Summary("The file it was declared in; absent where it was not declared in any file of the request")]
+ string? File,
+ [Summary("The line of the declaration, counting from zero")]
+ int Line,
+ [Summary("The column the declared name starts at, counting from zero")]
+ int Column,
+ [Summary("How many characters the declared name spans")]
+ int Length
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Data.cs b/src/GenHTTP.Lambda/Api/Model/Data.cs
index 330ad095..8b9b7bf0 100644
--- a/src/GenHTTP.Lambda/Api/Model/Data.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Data.cs
@@ -1,111 +1,140 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
///
/// One kind of data of a lambda, as its owner sees it.
///
///
-/// Data belongs to the lambda rather than to a version: every version reads
-/// and writes the same, deploying or rolling back leaves it alone, and it goes
-/// only with the lambda - or when its owner switches the kind off, which
-/// deletes what it held. The figures are the ones every kind can give, so a
-/// kind added later is listed the same way.
+/// The figures are the ones every kind can give, so a kind added later is
+/// listed the same way.
///
-///
-/// Which kind of data: database, the SQLite database the lambda keeps its
-/// records in, workspace, the files it reads and writes, or secrets,
-/// the API keys and passwords it reads and nobody sees
-///
-/// Whether the lambda has it
-/// Whether a lambda has it until its owner decides otherwise
-/// When the owner last switched it; absent while it is as it came
-/// What it holds: tables, for the database; files, for the workspace; values, for the secrets
-/// The room that takes, as the quota counts it; zero for a kind counted in things
-/// The room the tier of the lambda gives it; zero for a kind counted in things
-/// How many things it may hold, for a kind counted in things such as the secrets
-public sealed record DataStoreResponse(string Kind, bool Enabled, bool Default, DateTime? Changed, int Items, long UsedBytes, long QuotaBytes,
- int? MaxItems = null);
+[Summary("One kind of data of a lambda, and how full it is.")]
+public sealed record DataStoreResponse(
+ [Summary("Which kind: `database`, the SQLite database it keeps its records in; `workspace`, the files it reads and writes; or `secrets`, the API keys and passwords it reads and nobody sees")]
+ string Kind,
+ [Summary("Whether the lambda has it switched on")]
+ bool Enabled,
+ [Summary("Whether a lambda has it until its owner decides otherwise: the workspace does, the others do not")]
+ bool Default,
+ [Summary("When it was last switched on or off; absent while it is as it came")]
+ DateTime? Changed,
+ [Summary("What it holds: tables, for the database; files, for the workspace; values, for the secrets")]
+ int Items,
+ [Summary("The room that takes, as the allowance counts it; zero for the secrets, which are counted in values")]
+ long UsedBytes,
+ [Summary("The room it may take: for the database and the workspace, the data allowance of the tier less what the other takes; zero for the secrets")]
+ long QuotaBytes,
+ [Summary("How many it may hold, for a kind counted in values rather than bytes - the secrets")]
+ int? MaxItems = null
+);
-///
-/// One secret, as anybody but the lambda gets to see it: never its value.
-///
-/// What the code reads it by: Secret.Read("STRIPE_KEY")
-/// When it was first stored
-/// When its value was last replaced
-/// Whether the code reads it by that name
-public sealed record SecretResponse(string Name, DateTime Created, DateTime Changed, bool Used);
+[Summary("A secret, as anybody but the lambda gets to see it: never its value.")]
+public sealed record SecretResponse(
+ [Summary("What the code reads it by: `Secret.Read(\"STRIPE_KEY\")`")]
+ string Name,
+ [Summary("When it was first stored")]
+ DateTime Created,
+ [Summary("When its value was last replaced")]
+ DateTime Changed,
+ [Summary("Whether the code reads it by that name")]
+ bool Used
+);
-///
-/// The secrets of a lambda, or of a feature's copy of them.
-///
-/// Whether the lambda has secrets switched on (PUT …/data/secrets)
-/// What is stored, by name
-///
-/// Every name the code reads with Secret.Read("…") or Secret.Exists("…")
-/// as a literal - the version online and the newest one, or the feature's files
-///
-///
-/// The names the code reads that have no value yet, and does not ask about
-/// with Secret.Exists first: what somebody still has to set, or it fails
-///
-/// The names the code asks about with Secret.Exists that have no value: it does without them
-/// How many secrets there may be
-public sealed record SecretListingResponse(bool Enabled, List Secrets, List Used, List Missing, List Optional,
- int Limit);
+[Summary("The secrets of a lambda, or of a feature's copy of them, by name.")]
+public sealed record SecretListingResponse(
+ [Summary("Whether the lambda has its secrets switched on (`PUT …/data/secrets`)")]
+ bool Enabled,
+ [Summary("What is stored, by name")]
+ List Secrets,
+ [Summary("Every name the code reads with `Secret.Read(\"…\")` or `Secret.Exists(\"…\")` as a literal - in the version online and the newest one, or in the feature's files")]
+ List Used,
+ [Summary("The names the code reads that have no value, and that it does not ask about with `Secret.Exists` first: what somebody still has to enter, or the code that reads them fails")]
+ List Missing,
+ [Summary("The names the code asks about with `Secret.Exists` that have no value: it does without them")]
+ List Optional,
+ [Summary("How many secrets a lambda may keep")]
+ int Limit
+);
-///
-/// The value to store under a name.
-///
-/// The value, up to 32 KB - sent once, and never shown again
-public sealed record SecretRequest(string Value);
+[Summary("The value to store under a name.")]
+public sealed record SecretRequest(
+ [Summary("The value, up to 32 KB - sent once, and never shown again")]
+ string Value
+);
-///
-/// The database of a lambda, or a feature's copy of it.
-///
-/// Whether the lambda has its database switched on (PUT …/data/database)
-/// The room it takes on disk
-/// How large it may grow in the tier of the lambda
-/// Every table and view, tables first
-///
-/// Whether the code connects to it with Database.GetConnection() - the
-/// version online and the newest one, or the feature's files
-///
-public sealed record DatabaseResponse(bool Enabled, long UsedBytes, long QuotaBytes, List Tables, bool Used);
+[Summary("The database of a lambda, or a feature's copy of it.")]
+public sealed record DatabaseResponse(
+ [Summary("Whether the lambda has its database switched on (`PUT …/data/database`)")]
+ bool Enabled,
+ [Summary("The room it takes on disk")]
+ long UsedBytes,
+ [Summary("How large it may grow: the data allowance of the tier less what the workspace takes")]
+ long QuotaBytes,
+ [Summary("Every table and view, tables first")]
+ List Tables,
+ [Summary("Whether the code connects to it with `Database.GetConnection()` - in the version online and the newest one, or in the feature's files")]
+ bool Used
+);
-///
-/// One table or view of a database.
-///
-/// table or view
-/// How many rows it holds; absent where counting took too long
-/// Whether it is the history Evolve keeps of the migrations it applied
-public sealed record DatabaseTableResponse(string Name, string Kind, long? Rows, List Columns, bool Migrations);
+[Summary("A table or view of a database.")]
+public sealed record DatabaseTableResponse(
+ [Summary("Its name")]
+ string Name,
+ [Summary("`table` or `view`")]
+ string Kind,
+ [Summary("How many rows it holds; absent where counting took too long")]
+ long? Rows,
+ [Summary("Its columns, in the order they were declared")]
+ List Columns,
+ [Summary("Whether it is the history Evolve keeps of the migrations it applied, rather than records of the app")]
+ bool Migrations
+);
-///
-/// One column of a table or view.
-///
-/// The type it was declared with, as written
-/// Whether it is the primary key, or part of it
-/// The expression it defaults to, as written
-public sealed record DatabaseColumnResponse(string Name, string Type, bool NotNull, bool PrimaryKey, string? Default);
+[Summary("A column of a table or view.")]
+public sealed record DatabaseColumnResponse(
+ [Summary("Its name")]
+ string Name,
+ [Summary("The type it was declared with, as written: `INTEGER`, `TEXT`, or anything else SQLite accepts")]
+ string Type,
+ [Summary("Whether it was declared `NOT NULL`")]
+ bool NotNull,
+ [Summary("Whether it is the primary key, or part of it")]
+ bool PrimaryKey,
+ [Summary("The expression it defaults to, as written")]
+ string? Default
+);
-///
-/// A page of the rows of a table.
-///
-///
-/// Each row as its values, in the order of the columns: numbers, text, null -
-/// an integer beyond what JavaScript holds exactly as text, text of more than
-/// 2000 characters as { text, length }, and bytes as { blob }, their length
-///
-/// How many rows the table holds
-/// The column they are sorted by; absent for the order they were written in
-public sealed record DatabaseRowsResponse(string Table, List Columns, List> Rows, long Total, int Offset, int Limit,
- string? Order, bool Descending);
+[Summary("A page of the rows of a table or view.")]
+public sealed record DatabaseRowsResponse(
+ [Summary("The name of the table")]
+ string Table,
+ [Summary("Its columns, in the order of the values of each row")]
+ List Columns,
+ [Summary("Each row as its values, in the order of the columns: a number, a text or null - an integer beyond what JavaScript holds exactly as text, a text of more than 2000 characters as `{ text, length }`, and bytes as `{ blob }`, their length")]
+ List> Rows,
+ [Summary("How many rows the table holds")]
+ long Total,
+ [Summary("How many rows were skipped")]
+ int Offset,
+ [Summary("How many rows were asked for at most")]
+ int Limit,
+ [Summary("The column the rows are sorted by; absent for the order they were written in")]
+ string? Order,
+ [Summary("Whether the order is reversed: newest, or highest, first")]
+ bool Descending
+);
-///
-/// A text too long to send whole: its beginning, and how long it is.
-///
-public sealed record TextValue(string Text, int Length);
+[Summary("A text too long to send whole: its beginning, and how long it is.")]
+public sealed record TextValue(
+ [Summary("Its first 2000 characters")]
+ string Text,
+ [Summary("How many characters it has")]
+ int Length
+);
-///
-/// A value of bytes, told by its length.
-///
-public sealed record BlobValue(long Blob);
+[Summary("A value of bytes, told by its length.")]
+public sealed record BlobValue(
+ [Summary("How many bytes it holds")]
+ long Blob
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Demos.cs b/src/GenHTTP.Lambda/Api/Model/Demos.cs
index 203b9951..38596adc 100644
--- a/src/GenHTTP.Lambda/Api/Model/Demos.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Demos.cs
@@ -1,24 +1,27 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
-///
-/// A demo the installation keeps online, and how to read it.
-///
-/// What reading its code teaches
-/// The kinds of request it is the right starting point for
-/// Where it answers
-/// Its editor key: public, and read only
-/// Where it answers, below the hosting domain
-/// The files it is made of, the snippet first
-/// Whether it is answering right now - they are prepared after startup
+[Summary("A demo the installation keeps online, and how to read it.")]
public sealed record DemoResponse(
+ [Summary("What it is started from: the `template` of `POST /lambdas`")]
string Id,
+ [Summary("What it is called")]
string Name,
+ [Summary("What it is")]
string Description,
+ [Summary("What reading its code teaches")]
string Shows,
+ [Summary("The kinds of request it is the right starting point for")]
string ReadWhen,
+ [Summary("Where it answers: its public key")]
string PublicKey,
+ [Summary("Its editor key: public, and read only - every change is refused")]
string PrivateKey,
+ [Summary("Where it answers, below the hosting domain")]
string Address,
+ [Summary("The files it is made of, the snippet first")]
IReadOnlyList Files,
+ [Summary("Whether it answers right now - the demos are prepared after the server started")]
bool Live
);
diff --git a/src/GenHTTP.Lambda/Api/Model/Deployments.cs b/src/GenHTTP.Lambda/Api/Model/Deployments.cs
index 25ce1af8..98106326 100644
--- a/src/GenHTTP.Lambda/Api/Model/Deployments.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Deployments.cs
@@ -1,38 +1,49 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// Which version to put online. Defaults to the most recent one.
-///
-public sealed record DeploymentRequest(int? Version);
+[Summary("Which version to put online.")]
+public sealed record DeploymentRequest(
+ [Summary("The number of the version; left out, the newest")]
+ int? Version
+);
-///
-/// What is online, and for how long.
-///
-/// Whether anything is being served
-/// The version that is served
-/// When it went online
-/// When it will be taken offline again
-public sealed record DeploymentResponse(bool Deployed, int? Version, DateTime? DeployedAt, DateTime? DeployedUntil);
+[Summary("What is online, and for how long.")]
+public sealed record DeploymentResponse(
+ [Summary("Whether a version is online")]
+ bool Deployed,
+ [Summary("The version that is online; absent while none is")]
+ int? Version,
+ [Summary("When it went online")]
+ DateTime? DeployedAt,
+ [Summary("When it is taken offline unless it is visited or edited before then; absent for a tier that keeps it online")]
+ DateTime? DeployedUntil
+);
-///
-/// The result of an attempt to put a version online.
-///
-/// The lambda afterwards, where the attempt succeeded
-/// What the compiler said on the way
-public sealed record DeploymentOutcomeResponse(bool Success, LambdaResponse? Lambda, IReadOnlyList Diagnostics);
+[Summary("What came of an attempt to put a version online.")]
+public sealed record DeploymentOutcomeResponse(
+ [Summary("Whether it compiled and is online now. Where it is not, whatever was online before still is.")]
+ bool Success,
+ [Summary("The lambda afterwards; absent where nothing about it could be read")]
+ LambdaResponse? Lambda,
+ [Summary("What the compiler said: the errors that kept it offline, or the warnings of a version that went online")]
+ IReadOnlyList Diagnostics
+);
-///
-/// One stretch of time a version was online.
-///
-/// What was online
-/// When it went online
-/// Who put it online: api, agent, admin or system
-/// When it went offline, absent while it is still online
-///
-/// Why it went offline: replaced by another deployment, stopped by the owner,
-/// expired because nobody used it, or taken down by the operator
-///
-/// How long it was online, or has been so far
-public sealed record ActivationResponse(int Version, DateTime Started, string? Origin, DateTime? Ended, string? EndedBy, long Seconds);
+[Summary("One stretch of time a version was online.")]
+public sealed record ActivationResponse(
+ [Summary("The version that was online")]
+ int Version,
+ [Summary("When it went online")]
+ DateTime Started,
+ [Summary("Who put it online: `api`, `agent`, `git`, `admin` (the operator) or `system`")]
+ string? Origin,
+ [Summary("When it went offline; absent while it is still online")]
+ DateTime? Ended,
+ [Summary("Why it went offline: `replaced` by another deployment, `stopped` by its owner, `expired` because nobody used it, or taken down by the operator (`admin`)")]
+ string? EndedBy,
+ [Summary("How long it was online, or has been so far, in seconds")]
+ long Seconds
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Domains.cs b/src/GenHTTP.Lambda/Api/Model/Domains.cs
index 46119d1f..1bd9906d 100644
--- a/src/GenHTTP.Lambda/Api/Model/Domains.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Domains.cs
@@ -1,23 +1,31 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
-///
-/// The domain of a lambda, as its owner configures it.
-///
-/// The domain it is configured to answer at, if any
-/// The tier of the lambda, which decides whether it may have one
-/// Whether the tier includes a domain of its own
-/// Whether requests to the domain reach the lambda right now
-/// What the domain resolves to, as seen from the server; absent without a domain
-public sealed record DomainResponse(string? Domain, string Tier, bool Allowed, bool Served, DnsResponse? Dns);
+[Summary("The domain of a lambda, and whether it answers at it.")]
+public sealed record DomainResponse(
+ [Summary("The domain it is configured to answer at; absent without one")]
+ string? Domain,
+ [Summary("The tier of the lambda, which decides whether it may have one")]
+ string Tier,
+ [Summary("Whether the tier includes a domain of its own: only the premium tier does")]
+ bool Allowed,
+ [Summary("Whether requests to the domain reach the lambda right now - it has one, and its tier includes it. A domain kept from a premium tier is not served.")]
+ bool Served,
+ [Summary("What the domain resolves to, as the server sees it; absent without a domain")]
+ DnsResponse? Dns
+);
-///
-/// What a domain resolves to, looked up by the server as the request was answered.
-///
///
/// Only the addresses, and not whether they are the right ones: which
/// addresses this installation answers at is not something it can reliably
/// tell about itself from inside a container.
///
-/// Every address the name resolved to, IPv4 and IPv6 alike
-/// Why nothing could be resolved, where nothing could
-public sealed record DnsResponse(IReadOnlyList Addresses, string? Problem);
+[Summary("What a domain resolves to, looked up by the server as the request was answered.")]
+[Remarks("Only the addresses - not whether they are this installation's, which is for whoever knows where it runs to compare.")]
+public sealed record DnsResponse(
+ [Summary("Every address the domain resolved to, IPv4 and IPv6 alike")]
+ IReadOnlyList Addresses,
+ [Summary("Why nothing could be resolved, where nothing could: it does not resolve yet, or the name servers could not be asked")]
+ string? Problem
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Errors.cs b/src/GenHTTP.Lambda/Api/Model/Errors.cs
index 5ed7e4f2..fcba4609 100644
--- a/src/GenHTTP.Lambda/Api/Model/Errors.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Errors.cs
@@ -1,6 +1,17 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
///
/// How an error is reported to the single page application.
///
-public sealed record ErrorResponse(int Status, string Error, string Message);
+[Summary("Why a request was refused.")]
+[Remarks("A request refused before it reached the platform's own checks - a body that is not valid JSON, a build agent that cannot be reached - carries `status` and `message` only.")]
+public sealed record ErrorResponse(
+ [Summary("The status code it was answered with")]
+ int Status,
+ [Summary("What kind of refusal it is: `Invalid` (400), `Forbidden` (403), `NotFound` (404), `Conflict` (409) or `TooMany` (429)")]
+ string Error,
+ [Summary("What went wrong, written for a person or an agent to act on - often with what to do instead")]
+ string Message
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Features.cs b/src/GenHTTP.Lambda/Api/Model/Features.cs
index 1d666ec4..a703ea61 100644
--- a/src/GenHTTP.Lambda/Api/Model/Features.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Features.cs
@@ -1,106 +1,123 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// Starts a feature: a copy of a version's files, and of the lambda's data, to
-/// work on without touching the lambda.
-///
-/// What it is, in a few words - "Leaderboard", "Dark mode"
-/// What the user wants from it and why; the version it is merged into keeps it
-/// The version to branch off. Left out, the newest.
-public sealed record CreateFeatureRequest(string? Name, string? Specification = null, int? Base = null);
+[Summary("Starts a feature: a copy of a version's files, and of the lambda's data, to work on without touching the lambda.")]
+public sealed record CreateFeatureRequest(
+ [Summary("What it is, in a few words - \"Leaderboard\", \"Dark mode\". One line, 80 characters at most.")]
+ string? Name,
+ [Summary("What the user wants from it and why. The version it is merged into keeps it.")]
+ string? Specification = null,
+ [Summary("The number of the version to start from; left out, the newest")]
+ int? Base = null
+);
-///
-/// Changes a feature. What is left out stays as it is.
-///
-/// What it is, in a few words
-/// What the user wants from it and why
-/// What it changes, in a line - what the version it is merged into will say
-///
-/// The version it is now based on. Set once the changes of that version are in the feature's files: only a feature
-/// based on the newest version can be merged, and nothing checks that the changes really are in.
-///
-public sealed record UpdateFeatureRequest(string? Name = null, string? Specification = null, string? Change = null, int? Base = null);
+[Summary("Changes a feature. What is left out stays as it is.")]
+public sealed record UpdateFeatureRequest(
+ [Summary("What it is, in a few words")]
+ string? Name = null,
+ [Summary("What the user wants from it and why; empty clears it")]
+ string? Specification = null,
+ [Summary("What it changes, in one line - what the version it is merged into will say; empty clears it")]
+ string? Change = null,
+ [Summary("The version it is based on from now on. Move it only once the changes of that version are in the feature's files: only a feature based on the newest version can be merged, and nothing checks that the changes really are in.")]
+ int? Base = null
+);
-///
-/// Every file of a feature, replacing the ones it has.
-///
-/// Every file, lambda.cs first
-/// What the user wants from it and why; left out, the one it has is kept
-/// What it changes, in a line; left out, the one it has is kept
-///
-/// The save of the feature these files were made from; when given, the save is refused with 409 if the
-/// feature was saved again since, instead of replacing that save without a trace
-///
-public sealed record FeatureFilesRequest(IReadOnlyList? Files, string? Specification = null, string? Change = null,
- int? Revision = null);
+[Summary("Every file of a feature, replacing the ones it has.")]
+public sealed record FeatureFilesRequest(
+ [Summary("Every file, `lambda.cs` first")]
+ IReadOnlyList? Files,
+ [Summary("What the user wants from it and why; left out, the one it has is kept")]
+ string? Specification = null,
+ [Summary("What it changes, in one line; left out, the one it has is kept")]
+ string? Change = null,
+ [Summary("The `revision` of the feature these files were made from. Given, the save is refused with 409 if the feature was saved again since, instead of replacing that save without a trace.")]
+ int? Revision = null
+);
-///
-/// Merges a feature into the lambda.
-///
-/// Whether to put the version it becomes online as well
-/// What the version keeps as what the user wanted; left out, the feature's
-/// What the version says it changes; left out, the feature's
-public sealed record MergeFeatureRequest(bool? Deploy = null, string? Specification = null, string? Change = null);
+[Summary("How to merge a feature into the lambda.")]
+public sealed record MergeFeatureRequest(
+ [Summary("Whether to put the version it becomes online as well")]
+ bool? Deploy = null,
+ [Summary("What the version keeps as what the user wanted; left out, the feature's")]
+ string? Specification = null,
+ [Summary("What the version says it changes; left out, the feature's, or that it merges the feature")]
+ string? Change = null
+);
-///
-/// A feature: a change being worked on beside the lambda.
-///
-/// What it is addressed by in this API, and where its preview answers
-/// The branch it is in the lambda's git repository - given when it starts, and kept when it is renamed
-/// The version it is based on
-/// The newest version of the lambda
-/// Whether it can be merged: it is based on the newest version
-/// Whether its preview is online
-/// Whether its preview is online and serves what the feature holds now, rather than an earlier save
-/// When its preview was last put online, while it is
-/// Where its preview answers on this server, whether it is online or not
-/// How many times its files were saved, counting the ones it began with - to notice a save made elsewhere
+[Summary("A feature: a change being worked on beside the lambda.")]
public sealed record FeatureResponse(
+ [Summary("What every call about it takes, and what its preview is reached by")]
string Key,
+ [Summary("What it is, in a few words")]
string Name,
+ [Summary("The branch it is in the lambda's git repository - given when it starts, and kept when it is renamed")]
string Branch,
+ [Summary("What the user wants from it and why")]
string? Specification,
+ [Summary("What it changes, in one line")]
string? Change,
+ [Summary("The version it is based on")]
int Base,
+ [Summary("The newest version of the lambda")]
int? Newest,
+ [Summary("Whether it can be merged: it is based on the newest version")]
bool Mergeable,
+ [Summary("How it was started: `api`, `agent` or `git`")]
string? Origin,
+ [Summary("When it was started")]
DateTime Created,
+ [Summary("When it was last saved or changed")]
DateTime Modified,
+ [Summary("Whether its preview is online")]
bool Online,
+ [Summary("Whether its preview is online and serves what the feature holds now, rather than an earlier save")]
bool Current,
+ [Summary("When its preview was last put online, while it is")]
DateTime? Previewed,
+ [Summary("Where its preview answers on this site, whether it is online or not: `/features/{key}/`")]
string PreviewPath,
+ [Summary("How many times its files were saved, counting the ones it began with. Passed back with a save, it keeps that save from replacing one made elsewhere.")]
int Revision
);
-///
-/// A feature with the files it holds.
-///
-/// Every file, lambda.cs first
-public sealed record FeatureContentResponse(FeatureResponse Feature, IReadOnlyList Files);
+[Summary("A feature with its files.")]
+public sealed record FeatureContentResponse(
+ [Summary("The feature")]
+ FeatureResponse Feature,
+ [Summary("Its files, `lambda.cs` first - all of them, or those of the folder asked for")]
+ IReadOnlyList Files
+);
-///
-/// The result of putting the preview of a feature online.
-///
-/// The feature afterwards
-/// What the compiler said on the way
-public sealed record FeaturePreviewResponse(bool Success, FeatureResponse Feature, IReadOnlyList Diagnostics);
+[Summary("What came of putting the preview of a feature online.")]
+public sealed record FeaturePreviewResponse(
+ [Summary("Whether it compiled and the preview is online with it")]
+ bool Success,
+ [Summary("The feature afterwards")]
+ FeatureResponse Feature,
+ [Summary("What the compiler said: the errors that kept it offline, or the warnings of a preview that went online")]
+ IReadOnlyList Diagnostics
+);
-///
-/// A feature that was just saved.
-///
-/// The outcome of putting its preview online, if that was asked for
-public sealed record FeatureSavedResponse(FeatureResponse Feature, FeaturePreviewResponse? Preview);
+[Summary("A feature that was just saved.")]
+public sealed record FeatureSavedResponse(
+ [Summary("The feature afterwards")]
+ FeatureResponse Feature,
+ [Summary("What came of putting its preview online, where that was asked for (`?deploy=true`); absent otherwise")]
+ FeaturePreviewResponse? Preview
+);
-///
-/// The result of merging a feature.
-///
-/// Whether its files became a version, and the feature is gone
-/// The version it became
-/// Why it was not merged, where its code does not compile
-/// The outcome of putting that version online, if that was asked for
-public sealed record FeatureMergeResponse(bool Merged, VersionResponse? Version, IReadOnlyList Diagnostics,
- DeploymentOutcomeResponse? Deployment);
+[Summary("What came of merging a feature.")]
+public sealed record FeatureMergeResponse(
+ [Summary("Whether its files became a version, and the feature is gone")]
+ bool Merged,
+ [Summary("The version it became")]
+ VersionResponse? Version,
+ [Summary("Why it was not merged, where its code does not compile")]
+ IReadOnlyList Diagnostics,
+ [Summary("What came of putting that version online, where that was asked for")]
+ DeploymentOutcomeResponse? Deployment
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Files.cs b/src/GenHTTP.Lambda/Api/Model/Files.cs
index 2e5a57ac..65031996 100644
--- a/src/GenHTTP.Lambda/Api/Model/Files.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Files.cs
@@ -1,15 +1,21 @@
using System.Text.Json.Serialization;
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// The content of a workspace file, base64 encoded so anything can travel.
-///
-public sealed record FileResponse(string Path, [property: JsonConverter(typeof(LongStringConverter))] string Content, int Size);
+[Summary("A file of a workspace, base64 encoded so anything can travel in JSON.")]
+public sealed record FileResponse(
+ [Summary("Its path within the workspace, with forward slashes")]
+ string Path,
+ [Summary("Its bytes, base64 encoded")]
+ [property: JsonConverter(typeof(LongStringConverter))] string Content,
+ [Summary("How many bytes it holds")]
+ int Size);
-///
-/// A file to write into the workspace, base64 encoded.
-///
-public sealed record FileRequest(string? Content);
+[Summary("A file to write into a workspace.")]
+public sealed record FileRequest(
+ [Summary("Its bytes, base64 encoded; empty for an empty file")]
+ string? Content);
diff --git a/src/GenHTTP.Lambda/Api/Model/Lambdas.cs b/src/GenHTTP.Lambda/Api/Model/Lambdas.cs
index 3b837190..9955a373 100644
--- a/src/GenHTTP.Lambda/Api/Model/Lambdas.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Lambdas.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Data.Entities;
using GenHTTP.Lambda.Services.Hosting;
using GenHTTP.Lambda.Services.Meta;
@@ -5,58 +7,61 @@
namespace GenHTTP.Lambda.Api.Model;
-///
-/// Asks for a new lambda. The key is optional, a short random one is generated
-/// if none is given.
-///
-///
-/// How its editor opens: Full, the default, or Simple - the app, how it is
-/// doing and where to ask for a change, for somebody who is not going to
-/// read the code
-///
-public sealed record CreateLambdaRequest(string? PublicKey, bool AcceptedTerms, string? Template = null, string? View = null);
+[Summary("Asks for a new lambda.")]
+public sealed record CreateLambdaRequest(
+ [Summary("The public key to claim, which names the address: 3 to 40 characters of a-z, 0-9 and dashes. Left out, a random one is chosen.")]
+ string? PublicKey,
+ [Summary("Whether the terms of the installation (`terms` of `GET /system`) were accepted. Nothing is created unless this is true.")]
+ bool AcceptedTerms,
+ [Summary("What the lambda starts as: the `id` of a demo, as `GET /demos` and the starters of `GET /system` list them, or `empty`. Left out, empty.")]
+ string? Template = null,
+ [Summary("How its editor opens: `Full` (the default) with the code, files and versions, or `Simple` - the app, how it is doing and where to ask for a change, for somebody who is not going to read the code. Case does not matter.")]
+ string? View = null
+);
-///
-/// The changes to make to a lambda. What is left out stays as it is.
-///
-/// The key the lambda should move to
-/// How its editor opens for somebody who has not chosen a view of their own: Full or Simple
-public sealed record UpdateLambdaRequest(string? PublicKey, string? View = null);
+[Summary("The changes to make to a lambda. What is left out stays as it is.")]
+public sealed record UpdateLambdaRequest(
+ [Summary("The public key to move the lambda to, under the same rules as when it is created")]
+ string? PublicKey,
+ [Summary("How its editor opens for somebody who has not chosen a view of their own: `Full` or `Simple`")]
+ string? View = null
+);
-///
-/// A lambda as the editor sees it.
-///
-/// When it goes offline unless used; absent while offline, or when its tier keeps it online
-/// When it is removed unless used; absent when its tier keeps it
-/// The domain it is configured to answer at, whether or not its tier lets it
-///
-/// Its address below the hosting domain, named after its key - which sends its visitors on to its domain while it
-/// answers at one
-///
-/// Whether it actually answers at that domain - it has one, and its tier includes it
-/// Where to link to it: its domain while that is served, its address below the hosting domain otherwise
-/// How its editor opens for somebody who has not chosen a view of their own: Full or Simple
-///
-/// Where it is cloned with git, and pushed to: its versions are the commits of main, its features the other
-/// branches. Holds the editor key, like the editor's path
-///
+[Summary("A lambda, as whoever holds its editor key sees it.")]
public sealed record LambdaResponse(
+ [Summary("Names its address below the hosting domain. Not a secret; its owner may change it.")]
string PublicKey,
+ [Summary("The editor key, which authorizes every call about the lambda. Treat it like a password.")]
string PrivateKey,
+ [Summary("`Free`, `Premium` or `Demo`, assigned by the operator. Decides its limits, whether it is kept online while nobody uses it, and whether it may answer at a domain of its own.")]
string Tier,
+ [Summary("When it was created")]
DateTime Created,
+ [Summary("When something about it last changed: a save, a deployment, a setting")]
DateTime Modified,
+ [Summary("The version that is online; absent while nothing is")]
int? ActiveVersion,
+ [Summary("The newest version saved, which may be ahead of the one online")]
int? LatestVersion,
+ [Summary("Its address below the hosting domain, named after its public key: `https://quiz.genhttp.run/`. While it answers at a domain of its own, this address sends its visitors on to the domain.")]
string PublicUrl,
+ [Summary("Where its editor is on this site: `/editor/{privateKey}`. Holds the editor key.")]
string EditorPath,
+ [Summary("When the version online went online; absent while nothing is")]
DateTime? DeployedAt,
+ [Summary("When it is taken offline unless it is visited or edited before then - each visit and each edit moves this on. Absent while it is offline, and for a tier that keeps it online.")]
DateTime? DeployedUntil,
+ [Summary("When it is deleted unless it is visited or edited before then. Absent for a tier that keeps it.")]
DateTime? KeptUntil,
+ [Summary("The domain of its own it is configured to answer at, whether or not its tier lets it")]
string? Domain,
+ [Summary("Whether it answers at that domain: it has one, and its tier includes it")]
bool DomainServed,
+ [Summary("Where to link to it: its domain while that is served, `publicUrl` otherwise")]
string Address,
+ [Summary("How its editor opens for somebody who has not chosen a view of their own: `Full` or `Simple`")]
string View,
+ [Summary("Where it is cloned with git and pushed to, on this site. The versions are the commits of `main`, the features the other branches. Holds the editor key, like the editor's path.")]
string GitPath
);
@@ -136,13 +141,18 @@ public static class EditorViews
}
-///
-/// Everything anybody may know about a public key.
-///
-/// The key as it would be stored
-/// Whether it is a key a lambda could have
-/// Whether a new lambda could be created with it
-/// Whether a lambda has it
-/// Whether that lambda is online
-/// Why it cannot be claimed, if it cannot
-public sealed record KeyResponse(string PublicKey, bool Valid, bool Available, bool Exists, bool Deployed, string? Reason);
+[Summary("Everything anybody may know about a public key.")]
+public sealed record KeyResponse(
+ [Summary("The key as it would be stored: trimmed and lower cased")]
+ string PublicKey,
+ [Summary("Whether it is spelled as a key may be: 3 to 40 characters of a-z, 0-9 and dashes, not starting or ending with a dash")]
+ bool Valid,
+ [Summary("Whether a new lambda could be created with it right now")]
+ bool Available,
+ [Summary("Whether a lambda has it")]
+ bool Exists,
+ [Summary("Whether that lambda is online")]
+ bool Deployed,
+ [Summary("Why it cannot be claimed, where it cannot - spelling, a reserved name, a key kept for the demos")]
+ string? Reason
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Logs.cs b/src/GenHTTP.Lambda/Api/Model/Logs.cs
index 4e63834b..dd2e0d25 100644
--- a/src/GenHTTP.Lambda/Api/Model/Logs.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Logs.cs
@@ -1,61 +1,93 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
-///
-/// One line from the run of the server.
-///
-/// The lambda's own domain the request was addressed to, absent for the platform
-public sealed record LogEntry(long Seq, DateTime At, string Level, string Source, string? Lambda, string Text,
- string? Detail, string? Client, string? Agent, string? Country, string? Place,
- int Repeats, string? Domain);
+[Summary("A line of the server's log, as the operator sees it.")]
+public sealed record LogEntry(
+ [Summary("Its sequence: counts up and never repeats, so a reader asks for what came after the last one it saw")]
+ long Seq,
+ [Summary("When it was said")]
+ DateTime At,
+ [Summary("`trace`, `debug`, `info`, `warn`, `error` or `critical`")]
+ string Level,
+ [Summary("Who said it: `Requests` for a request line, `stdout` or `stderr` for what a lambda printed, or the part of the server that spoke")]
+ string Source,
+ [Summary("The public key of the lambda it belongs to; absent for the platform itself")]
+ string? Lambda,
+ [Summary("What was said")]
+ string Text,
+ [Summary("The stack trace, where there is one")]
+ string? Detail,
+ [Summary("The address of the caller, where addresses are recorded - with the address a proxy claimed for it, where one did")]
+ string? Client,
+ [Summary("What the caller said it was - its user agent")]
+ string? Agent,
+ [Summary("The two letter code of the country the caller's address is registered in, where it is known")]
+ string? Country,
+ [Summary("The place the caller's address is registered at, where places are looked up")]
+ string? Place,
+ [Summary("How many identical lines in a row this one stands for")]
+ int Repeats,
+ [Summary("The lambda's own host the request was addressed to; absent for the platform")]
+ string? Domain
+);
-///
-/// One caller the log still holds something about.
-///
-public sealed record LogCaller(string Client, string? Place, string? Country, string? Agent,
- long Lines, long Failed, DateTime First, DateTime Last);
+[Summary("A caller the log still holds something about.")]
+public sealed record LogCaller(
+ [Summary("Its address")]
+ string Client,
+ [Summary("The place its address is registered at, where known")]
+ string? Place,
+ [Summary("The two letter code of the country its address is registered in, where known")]
+ string? Country,
+ [Summary("What it said it was, last")]
+ string? Agent,
+ [Summary("How many lines of the log are about it")]
+ long Lines,
+ [Summary("How many of its requests were answered with an error")]
+ long Failed,
+ [Summary("When it was first seen in what the log holds")]
+ DateTime First,
+ [Summary("When it was last seen")]
+ DateTime Last
+);
-///
-/// A page of the log, and where to carry on from.
-///
-/// The newest sequence held; ask from here next time
-///
-/// Lines that were dropped before the reader got to them, because the ring
-/// wrapped or because more matched than were asked for
-///
-/// How many lines the ring holds in total
-/// Everything said since the server came up
-/// Whether what lambdas print is being kept
-/// Whether the address a request came from is being recorded
-///
-/// How the run before this one ended. Nothing on a first start - and on any
-/// start where the run before stopped the way it meant to, since a clean stop
-/// is not news.
-///
+[Summary("A page of the server's log, and where to carry on from.")]
public sealed record LogResponse(
+ [Summary("The lines, oldest first")]
IReadOnlyList Lines,
+ [Summary("The newest sequence held; pass it as `since` to read what comes after")]
long Cursor,
+ [Summary("Lines that were dropped before the reader got to them, because the ring went round or more matched than were asked for")]
int Missed,
+ [Summary("How many lines the ring holds in all")]
int Capacity,
+ [Summary("How many lines were written since the server came up")]
long Written,
+ [Summary("Whether what lambdas print is kept")]
bool Capturing,
+ [Summary("Whether the address a request came from is recorded")]
bool Addresses,
+ [Summary("How the run before this one ended, where it did not end cleanly; absent on a first start and after a clean stop")]
PreviousRun? Previous
);
-///
-/// The end of the run before this one, where it was not a clean one.
-///
-///
-/// Whether anything asked it to stop. Nothing having asked is what points at
-/// the machine rather than at an operator or a deployment.
-///
+[Summary("The end of the run of the server before this one, where it was not a clean one.")]
public sealed record PreviousRun(
+ [Summary("When it started")]
DateTime Started,
+ [Summary("When it was last seen alive")]
DateTime LastSeen,
+ [Summary("How long it ran, in minutes")]
double Minutes,
+ [Summary("Whether anything asked it to stop. Nothing having asked points at the machine rather than at an operator or a deployment.")]
bool Signalled,
+ [Summary("The exception that ended it, where one did")]
string? Fault,
+ [Summary("Its working set when it was last seen, in bytes")]
long WorkingSet,
+ [Summary("How many requests it had answered")]
long Requests,
+ [Summary("How many connections were open when it was last seen")]
int Sockets
);
diff --git a/src/GenHTTP.Lambda/Api/Model/Monitoring.cs b/src/GenHTTP.Lambda/Api/Model/Monitoring.cs
index b4ffb34f..5824c2ef 100644
--- a/src/GenHTTP.Lambda/Api/Model/Monitoring.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Monitoring.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Meta;
namespace GenHTTP.Lambda.Api.Model;
@@ -11,135 +13,156 @@ namespace GenHTTP.Lambda.Api.Model;
/// five because the question it answers - is this all right? - is one
/// question.
///
-/// The version that is online, with its note, if one is
-/// The newest version, which may be ahead of what is online
-/// How many versions are kept
-/// The last few warnings and errors, newest first
-/// What the documentation of the version the storage is about says, and which of its pages it has
+[Summary("Everything worth knowing about a lambda at a glance.")]
public sealed record LambdaSummaryResponse(
+ [Summary("The lambda")]
LambdaResponse Lambda,
+ [Summary("The version that is online, if one is")]
VersionResponse? Live,
+ [Summary("The newest version, which may be ahead of the one online")]
VersionResponse? Latest,
+ [Summary("How many versions are kept")]
int Versions,
+ [Summary("Since when the version online has been online; absent while none is")]
ActivationResponse? Activation,
+ [Summary("How much it is used, and how well it answers")]
TrafficSummary Traffic,
+ [Summary("The last few errors and warnings of its own log, newest first - not the requests that were merely refused")]
IReadOnlyList RecentProblems,
+ [Summary("What it keeps, and how much of its allowances that takes")]
StorageSummary Storage,
+ [Summary("What its tier allows it")]
SummaryLimits Limits,
+ [Summary("What the documentation of the version the storage is about says, and which of its pages it has")]
DocumentationSummary Documentation
);
-///
-/// How much a lambda is being used, and how well it is answering.
-///
///
/// Counted in memory since the server last started, which is
/// : a restart begins these again.
///
-/// Requests in the last hour
-/// Of those, answered with a server error
-/// Requests in the last day
-/// Of those, answered with a server error
-/// Of those, answered with a client error, not found included
-/// Mean time to answer over the last day
-/// Connections upgraded to a websocket in the last day
-/// Requests per hour over the last day, oldest first, for a sparkline
-/// When the last request arrived
+[Summary("How much a lambda is used, and how well it answers.")]
public sealed record TrafficSummary(
+ [Summary("Requests in the last hour")]
long HourRequests,
+ [Summary("Of those, answered with a server error")]
long HourFailed,
+ [Summary("Requests in the last day")]
long DayRequests,
+ [Summary("Of those, answered with a server error")]
long DayFailed,
+ [Summary("Of those, answered with a client error, not found included")]
long DayRejected,
+ [Summary("The mean time to answer over the last day, in milliseconds")]
double AverageMillis,
+ [Summary("Connections upgraded to a websocket in the last day")]
long Upgrades,
+ [Summary("Requests per hour over the last day, oldest first")]
IReadOnlyList Hourly,
+ [Summary("When the last request arrived")]
DateTime? LastSeen,
+ [Summary("When the counting started: when the server came up")]
DateTime Since
);
-///
-/// What a lambda keeps, and how much of each allowance it spends.
-///
-/// The version the code figures are about: the one online, else the newest
-/// Files of its code: the C# it is compiled from and whatever else it is kept with, never served
-/// What those weigh, towards the build allowance
-/// Its resources, which it reads and serves as they are when the code asks
-/// What those weigh, decoded, towards the build allowance
-/// Files in the workspace - the lambda's data, which it writes at runtime and every version shares
-/// The room those take, towards the data allowance
-/// Whether the code of that version reaches for Resources to serve them
-/// Whether it serves the workspace, which makes those files public
-/// Whether the owner left the workspace switched on
-/// Whether the code of that version uses the workspace at all, and so fails where it does once it is off
-/// Whether the lambda has secrets switched on
-/// How many secrets it keeps - by name only, anywhere but in the lambda
-///
-/// The secrets its code reads that have no value yet - which is what its owner
-/// is asked to set, because until then the code that reads them fails
-///
-/// Whether the lambda has its database switched on
-/// How many tables its database holds
-/// The room its database takes, towards the data allowance
-/// Whether the code of that version connects to the database, and so fails where it does once it is off
+[Summary("What a lambda keeps, and how much of each allowance it takes.")]
public sealed record StorageSummary(
+ [Summary("The version the figures about code are about: the one online, else the newest")]
int? Version,
+ [Summary("Files of its code: the C# it is compiled from and whatever else it is kept with, never served")]
int CodeFiles,
+ [Summary("What those weigh, towards what a version may come to")]
long CodeBytes,
+ [Summary("Its resources, which it reads and serves while it runs")]
int ResourceFiles,
+ [Summary("What those weigh, decoded, towards what a version may come to")]
long ResourceBytes,
+ [Summary("Files in the workspace, which it writes while it runs and every version shares")]
int WorkspaceFiles,
+ [Summary("The room those take, towards the data allowance")]
long WorkspaceBytes,
+ [Summary("Whether the code of that version serves its resources")]
bool ServesResources,
+ [Summary("Whether it serves the workspace, which makes those files public")]
bool ServesWorkspace,
+ [Summary("Whether the workspace is switched on")]
bool WorkspaceEnabled,
+ [Summary("Whether the code of that version uses the workspace at all, and so fails where it does once it is switched off")]
bool UsesWorkspace,
+ [Summary("Whether secrets are switched on")]
bool SecretsEnabled = false,
+ [Summary("How many secrets it keeps")]
int Secrets = 0,
+ [Summary("The secrets its code reads that have no value yet - what its owner still has to enter, because until then the code that reads them fails")]
List? MissingSecrets = null,
+ [Summary("Whether the database is switched on")]
bool DatabaseEnabled = false,
+ [Summary("How many tables the database holds")]
int DatabaseTables = 0,
+ [Summary("The room the database takes, towards the data allowance")]
long DatabaseBytes = 0,
+ [Summary("Whether the code of that version connects to the database, and so fails where it does once it is switched off")]
bool UsesDatabase = false
);
-///
-/// The allowances a lambda on this installation has, in its tier.
-///
///
/// Nothing counts files, only what they come to: a version, its code and its
/// resources together; and the data, its database and its workspace together.
///
-/// How large a version may be, its code and its resources together
-/// The room its data may take, its database and its workspace together
-/// How many features it may have open at once
+[Summary("What a lambda of its tier may have on this installation, as the operator set it.")]
public sealed record SummaryLimits(
+ [Summary("What a version may come to, its code and its resources together, in bytes")]
long BuildBytes,
+ [Summary("The room its data may take, its database and its workspace together, in bytes")]
long DataBytes,
+ [Summary("How many versions are kept; the oldest beyond that go with every save, but never the one online or one a feature is based on")]
int Versions,
+ [Summary("How long a free lambda may go without a visit or an edit before it is taken offline, in hours")]
int DeploymentLifetimeHours,
+ [Summary("How long a free lambda may go without a visit or an edit before it is deleted, in days")]
int RetentionDays,
+ [Summary("How many features it may have open at once")]
int Features
);
-///
-/// One line of a lambda's log, as its owner sees it.
-///
///
/// Without the address of the visitor or the town it resolves to. The owner
/// wrote the code and may read what it said, but the people who called it
/// did not agree to be pointed at; the country and what the client called
/// itself are as far as this goes.
///
-/// Requests, stdout, stderr, or the part of the server that spoke
-/// The host the request was addressed to: the lambda's subdomain of the hosting domain, or its own domain
-public sealed record OwnerLogEntry(long Seq, DateTime At, string Level, string Source, string Text, string? Detail,
- string? Country, string? Agent, int Repeats, string? Domain);
+[Summary("A line of a lambda's log, as its owner sees it - never with a visitor's address.")]
+public sealed record OwnerLogEntry(
+ [Summary("Its sequence: counts up and never repeats, so a reader asks for what came after the last one it saw")]
+ long Seq,
+ [Summary("When it was said")]
+ DateTime At,
+ [Summary("`trace`, `debug`, `info`, `warn`, `error` or `critical`")]
+ string Level,
+ [Summary("Who said it: `Requests` for a request line, `stdout` or `stderr` for what the code printed, or the part of the server that spoke")]
+ string Source,
+ [Summary("What was said")]
+ string Text,
+ [Summary("The stack trace, where there is one")]
+ string? Detail,
+ [Summary("The two letter code of the country the caller's address is registered in, where it is known")]
+ string? Country,
+ [Summary("What the caller said it was - its user agent")]
+ string? Agent,
+ [Summary("How many identical lines in a row this one stands for; one is itself alone")]
+ int Repeats,
+ [Summary("The host the request was addressed to: the lambda's subdomain of the hosting domain, or its own domain")]
+ string? Domain
+);
-///
-/// A page of a lambda's log, and where to carry on from.
-///
-/// The newest sequence held; ask from here next time
-/// Lines that were dropped before this reader reached them
-/// Whether what the lambda prints is being kept at all
-public sealed record OwnerLogResponse(IReadOnlyList Lines, long Cursor, int Missed, bool Capturing);
+[Summary("A page of a lambda's log, and where to carry on from.")]
+public sealed record OwnerLogResponse(
+ [Summary("The lines, oldest first")]
+ IReadOnlyList Lines,
+ [Summary("The newest sequence held; pass it as `since` to read what comes after")]
+ long Cursor,
+ [Summary("Lines that were dropped before this reader got to them, because the ring went round or more matched than were asked for")]
+ int Missed,
+ [Summary("Whether what lambdas print is kept at all on this installation")]
+ bool Capturing
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Paging.cs b/src/GenHTTP.Lambda/Api/Model/Paging.cs
index 858850f1..8eb831dd 100644
--- a/src/GenHTTP.Lambda/Api/Model/Paging.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Paging.cs
@@ -1,12 +1,16 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Api.Model;
-///
-/// One page of a listing that is read a page at a time, with skip and
-/// take.
-///
-/// How many there are altogether
-/// Where the next page starts, or nothing when this was the last
-public sealed record Page(IReadOnlyList Entries, int Total, int? Next)
+[Summary("A page of a listing that is read a page at a time, with `skip` and `take`.")]
+public sealed record Page(
+ [Summary("The entries of this page")]
+ IReadOnlyList Entries,
+ [Summary("How many entries there are altogether")]
+ int Total,
+ [Summary("The `skip` of the next page; absent when this was the last")]
+ int? Next
+)
{
///
diff --git a/src/GenHTTP.Lambda/Api/Model/Platform.cs b/src/GenHTTP.Lambda/Api/Model/Platform.cs
index 67a3d732..052e6e38 100644
--- a/src/GenHTTP.Lambda/Api/Model/Platform.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Platform.cs
@@ -1,35 +1,45 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// What the editor needs to know about the platform it talks to.
-///
-/// How large a version of a free lambda may be, its code and its resources together
-/// Where a lambda answers, with {key} where its public key goes: https://{key}.genhttp.run/
+[Summary("What this installation is like.")]
public sealed record PlatformResponse(
+ [Summary("The terms of service, which creating a lambda accepts (`acceptedTerms`)")]
string Terms,
+ [Summary("What a new lambda can start from, in the words of somebody who wants to build something")]
IReadOnlyList Starters,
+ [Summary("What a version of a free lambda may come to, its code and its resources together, in bytes")]
long BuildBytes,
+ [Summary("How long a free lambda may go without a visit or an edit before it is taken offline, in hours")]
int DeploymentLifetimeHours,
+ [Summary("How long a free lambda may go without a visit or an edit before it is deleted, in days")]
int RetentionDays,
+ [Summary("The namespaces a lambda has imported without saying so")]
IReadOnlyList Imports,
+ [Summary("The types, attributes and snippets the editor suggests while code is written")]
IReadOnlyList Completions,
+ [Summary("Whether the build agent is on offer, and how often")]
BuildAvailability Build,
+ [Summary("Where a lambda answers, with `{key}` where its public key goes: `https://{key}.genhttp.run/`")]
string LambdaUrl
);
-///
-/// Something a new lambda can be started from.
-///
-/// What to pass as the template when creating the lambda
-/// What somebody would want to build, in their words
-/// What they would get
-/// Where the demo it copies is running, to look at first; null for the empty lambda
-public sealed record StarterResponse(string Id, string Title, string Description, string? Demo);
+[Summary("Something a new lambda can be started from.")]
+public sealed record StarterResponse(
+ [Summary("What to pass as `template` when creating the lambda")]
+ string Id,
+ [Summary("What somebody would want to build, in their words")]
+ string Title,
+ [Summary("What they would get")]
+ string Description,
+ [Summary("Where the demo it copies answers, to look at first; absent for the empty lambda")]
+ string? Demo
+);
-///
-/// Which pages the site links to.
-///
-/// Whether the header links to the enterprise page, which is reachable either way
-public sealed record FeaturesResponse(bool Enterprise);
+[Summary("Which pages the site links to.")]
+public sealed record FeaturesResponse(
+ [Summary("Whether the header links to the enterprise page, which can be reached either way")]
+ bool Enterprise
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Showcases.cs b/src/GenHTTP.Lambda/Api/Model/Showcases.cs
index ddbc4494..af6e6543 100644
--- a/src/GenHTTP.Lambda/Api/Model/Showcases.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Showcases.cs
@@ -1,24 +1,31 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Hosting;
using GenHTTP.Lambda.Services.Showcase;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// A lambda in the showcase, as the page draws it.
-///
-/// Where it answers - its own domain while it has one, its address below the hosting domain otherwise
-/// Its picture, versioned so it can be cached for good
-/// Whether it answers right now - the page only lists those
+[Summary("A lambda in the showcase, as the showcase lists it.")]
public sealed record ShowcaseResponse(
+ [Summary("The public key of the lambda")]
string PublicKey,
+ [Summary("What its owner calls it")]
string Title,
+ [Summary("What its owner says it is, and what a visitor can do with it")]
string Description,
+ [Summary("Where it answers - its own domain while it has one, its address below the hosting domain otherwise")]
string Address,
+ [Summary("Where its picture is on this site; it changes with the picture, so it may be cached for good")]
string ImagePath,
+ [Summary("The media type of the picture: `image/png`, `image/jpeg`, `image/gif` or `image/webp`")]
string ImageType,
+ [Summary("How large the picture is, in bytes")]
int ImageBytes,
+ [Summary("Whether the lambda answers right now - the listing only has those")]
bool Online,
+ [Summary("When it was put into the showcase")]
DateTime Created,
+ [Summary("When its entry last changed")]
DateTime Updated
)
{
@@ -38,17 +45,34 @@ DateTime Updated
}
-///
-/// The entry of a lambda as its owner sees it, with what may go into one.
-///
-/// The entry, or nothing while the lambda is not in the showcase
-public sealed record OwnShowcaseResponse(ShowcaseResponse? Showcase, ShowcaseLimitsResponse Limits);
+[Summary("The showcase entry of a lambda as its owner sees it, with what may go into one.")]
+public sealed record OwnShowcaseResponse(
+ [Summary("The entry; absent while the lambda is not in the showcase")]
+ ShowcaseResponse? Showcase,
+ [Summary("What an entry may hold")]
+ ShowcaseLimitsResponse Limits
+);
-/// How an entry should read
-public sealed record ShowcaseLimitsResponse(int Title, int Description, int ImageBytes, IReadOnlyList ImageTypes, string Tone);
+[Summary("What a showcase entry may hold.")]
+public sealed record ShowcaseLimitsResponse(
+ [Summary("How many characters the title may have")]
+ int Title,
+ [Summary("How many characters the description may have")]
+ int Description,
+ [Summary("How large the picture may be, in bytes, as the operator set it")]
+ int ImageBytes,
+ [Summary("The media types a picture may have")]
+ IReadOnlyList ImageTypes,
+ [Summary("How an entry should read")]
+ string Tone
+);
-///
-/// What the entry should say.
-///
-/// The picture, base64 encoded. Left out, the one already there is kept.
-public sealed record ShowcaseRequest(string? Title, string? Description, string? Image);
+[Summary("What a showcase entry should say.")]
+public sealed record ShowcaseRequest(
+ [Summary("What it is called, on one line")]
+ string? Title,
+ [Summary("What it is, and what a visitor can do with it")]
+ string? Description,
+ [Summary("The picture, base64 encoded: a PNG, JPEG, GIF or WebP image. Required for a new entry; left out, the one there is kept.")]
+ string? Image
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Sources.cs b/src/GenHTTP.Lambda/Api/Model/Sources.cs
index 84c6ae3e..abbd801d 100644
--- a/src/GenHTTP.Lambda/Api/Model/Sources.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Sources.cs
@@ -1,15 +1,21 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Hosting;
using GenHTTP.Lambda.Services.Source;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// A license a source can be published under.
-///
-/// Its SPDX identifier, which is how it is asked for
-/// Permissive, Copyleft or PublicDomain - roughly what it asks of whoever reuses the code
-/// Where it is described in full
-public sealed record LicenseResponse(string Id, string Name, string Kind, string Url)
+[Summary("A license a source can be published under.")]
+public sealed record LicenseResponse(
+ [Summary("Its SPDX identifier, which is how it is asked for: `MIT`, `Apache-2.0`, `GPL-3.0-or-later` …")]
+ string Id,
+ [Summary("Its full name")]
+ string Name,
+ [Summary("`Permissive`, `Copyleft` or `PublicDomain` - roughly what it asks of whoever reuses the code")]
+ string Kind,
+ [Summary("Where it is described in full")]
+ string Url
+)
{
public static LicenseResponse Of(SourceLicense license) => new(license.Id, license.Name, license.Kind.ToString(), license.Url);
@@ -20,32 +26,33 @@ public sealed record LicenseResponse(string Id, string Name, string Kind, string
#region Public
-///
-/// A published source, as the listing shows it.
-///
-/// What its owner calls it on the showcase, if it is on the showcase
-/// What its newest version says it is - the first paragraph of its documentation
-/// What its owner says about it on the showcase, if it is there
-/// Whether the app answers right now
-/// Where the app answers - its own domain while it has one, its address below the hosting domain otherwise
-/// Where its source is read, without a language
-/// Its picture on the showcase, if it has one
-/// Its newest version
-/// When its newest version was saved
-/// When its source was published
+[Summary("A published source, as the listing shows it.")]
public sealed record SourceEntryResponse(
+ [Summary("The public key of the lambda")]
string PublicKey,
+ [Summary("What its owner calls it in the showcase, where it is in the showcase")]
string? Title,
+ [Summary("What its newest version says it is: the first paragraph of its `docs/product.md`")]
string? About,
+ [Summary("What its owner says about it in the showcase, where it is there")]
string? Description,
+ [Summary("The license it is published under")]
LicenseResponse License,
+ [Summary("How many visitors starred it")]
int Stars,
+ [Summary("Whether the app answers right now")]
bool Online,
+ [Summary("Where the app answers - its own domain while it has one, its address below the hosting domain otherwise")]
string Address,
+ [Summary("Where its source is read on this site: `/source/{publicKey}`")]
string Path,
+ [Summary("Its picture in the showcase, where it has one")]
string? ImagePath,
+ [Summary("Its newest version")]
int? LatestVersion,
+ [Summary("When its newest version was saved")]
DateTime? Updated,
+ [Summary("When its source was published")]
DateTime PublishedAt
)
{
@@ -68,84 +75,137 @@ DateTime PublishedAt
}
-///
-/// A published source with its history.
-///
-/// Who holds the copyright, if the owner named somebody
-/// Who the license names as holding it - the author, or the authors of the lambda
-/// The version online, if one is
-/// When the lambda was made
-/// Every version kept, newest first: what each changed, never what was asked for
-/// What starring it has to be sent with, good for a day from a second on
-/// Where it is cloned with git - every version, read only
+[Summary("A published source with its history.")]
public sealed record SourceProjectResponse(
+ [Summary("The source, as the listing shows it")]
SourceEntryResponse Source,
+ [Summary("Who holds the copyright, where the owner named somebody")]
string? Author,
+ [Summary("Who the license names as holding it: the author, or the authors of the lambda")]
string Holder,
+ [Summary("The version online, if one is")]
int? ActiveVersion,
+ [Summary("When the lambda was created")]
DateTime Created,
+ [Summary("Every version kept, newest first: what each changed, never what was asked for")]
IReadOnlyList Versions,
+ [Summary("What starring it is sent with (`POST …/star`): good from a second after it was handed out, for a day")]
string StarTicket,
+ [Summary("Where it is cloned with git on this site - every version, read only")]
string GitPath
);
-/// What it changed, in a line
-/// Which door it came through: template, api, agent or git
-/// Whether this is the version online
-/// Where it is downloaded as a project
-public sealed record SourceVersionResponse(int Version, DateTime Created, string? Change, string? Origin, bool Online, string ZipPath);
-
-///
-/// The files of a version, as the project it is packed into has them.
-///
-/// The folder the project is in, inside the zip
-/// How large the zip is
-/// Every file, by its path below the project's folder
-/// Where it is downloaded
-public sealed record SourceTreeResponse(string PublicKey, int Version, string Root, long Bytes, IReadOnlyList Files, string ZipPath);
-
-/// code, resource, docs, tests, platform or project
-public sealed record SourceFileResponse(string Path, long Size, string Kind);
-
-///
-/// One file of a version.
-///
-/// Whether it is text, which is then in Content
-/// The text, while it is text and not too long to show
-/// Where it is fetched as it is
-public sealed record SourceFileContentResponse(string Path, long Size, string Kind, bool Text, string? Content, string RawPath);
-
-///
-/// A star given, or taken back.
-///
-/// What the page was handed with the source
-/// True, or left out, to star it; false to take the star back
-public sealed record StarRequest(string? Ticket, bool? Starred);
-
-/// How many it has now
-/// Whether this one changed the count - it does not when the same was done from here before
-public sealed record StarResponse(int Stars, bool Starred, bool Counted);
+[Summary("A version of a published source.")]
+public sealed record SourceVersionResponse(
+ [Summary("Its number")]
+ int Version,
+ [Summary("When it was saved")]
+ DateTime Created,
+ [Summary("What it changed, in a line")]
+ string? Change,
+ [Summary("How it was saved: `template`, `api`, `agent` or `git`")]
+ string? Origin,
+ [Summary("Whether it is the version online")]
+ bool Online,
+ [Summary("Where it is downloaded as a project")]
+ string ZipPath
+);
+
+[Summary("The files of a published version, as the project it is packed into has them.")]
+public sealed record SourceTreeResponse(
+ [Summary("The public key of the lambda")]
+ string PublicKey,
+ [Summary("The number of the version")]
+ int Version,
+ [Summary("The folder the project is in, inside the zip")]
+ string Root,
+ [Summary("How large the zip is, in bytes")]
+ long Bytes,
+ [Summary("Every file, by its path below the project's folder")]
+ IReadOnlyList Files,
+ [Summary("Where the zip is downloaded")]
+ string ZipPath
+);
+
+[Summary("A file of a published version.")]
+public sealed record SourceFileResponse(
+ [Summary("Its path below the project's folder")]
+ string Path,
+ [Summary("How large it is, in bytes")]
+ long Size,
+ [Summary("What it is: `code`, `resource`, `docs`, `tests`, `platform` (what stands in for the platform) or `project` (what makes it a project)")]
+ string Kind
+);
+
+[Summary("A file of a published version, with its text.")]
+public sealed record SourceFileContentResponse(
+ [Summary("Its path below the project's folder")]
+ string Path,
+ [Summary("How large it is, in bytes")]
+ long Size,
+ [Summary("What it is: `code`, `resource`, `docs`, `tests`, `platform` or `project`")]
+ string Kind,
+ [Summary("Whether it is text - which is then in `content`, unless it is longer than a megabyte")]
+ bool Text,
+ [Summary("Its text, while it is text no longer than a megabyte")]
+ string? Content,
+ [Summary("Where it is fetched as it is")]
+ string RawPath
+);
+
+[Summary("A star given, or taken back.")]
+public sealed record StarRequest(
+ [Summary("The `starTicket` the source was read with")]
+ string? Ticket,
+ [Summary("`true`, or left out, to star it; `false` to take the star back")]
+ bool? Starred
+);
+
+[Summary("How a source is starred now.")]
+public sealed record StarResponse(
+ [Summary("How many stars it has now")]
+ int Stars,
+ [Summary("Whether this address stars it now")]
+ bool Starred,
+ [Summary("Whether this request changed the count - it does not where the same was done from here before")]
+ bool Counted
+);
#endregion
#region Owner
-///
-/// Whether a lambda's source is published, as its owner sees it, and what it
-/// can be published under.
-///
-/// What it is published under, or nothing when it never was
-/// The licenses on offer, the default first
-/// How long the name in the license may be
-public sealed record OwnSourceResponse(SourceSettingsResponse? Source, IReadOnlyList Licenses, string Default, int MaxAuthor);
-
-/// Whether anybody may read it right now
-/// Who holds the copyright, if the owner named somebody
-/// Who the license names - the author, or the authors of the lambda
-/// How many visitors starred it, kept while it is taken down
-/// Where it is read, without a language
-public sealed record SourceSettingsResponse(bool Published, LicenseResponse License, string? Author, string Holder, int Stars,
- DateTime PublishedAt, DateTime Updated, string Path)
+[Summary("Whether a lambda's source is published, as its owner sees it, and what it can be published under.")]
+public sealed record OwnSourceResponse(
+ [Summary("What it is published under; absent when it never was")]
+ SourceSettingsResponse? Source,
+ [Summary("The licenses on offer, the default first")]
+ IReadOnlyList Licenses,
+ [Summary("The license a source is published under unless another is asked for: `MIT`")]
+ string Default,
+ [Summary("How many characters the name in the license may have")]
+ int MaxAuthor
+);
+
+[Summary("What a lambda's source is published under.")]
+public sealed record SourceSettingsResponse(
+ [Summary("Whether anybody may read it right now - it may have been taken down since")]
+ bool Published,
+ [Summary("The license")]
+ LicenseResponse License,
+ [Summary("Who holds the copyright, where the owner named somebody")]
+ string? Author,
+ [Summary("Who the license names: the author, or the authors of the lambda")]
+ string Holder,
+ [Summary("How many visitors starred it - kept while it is taken down")]
+ int Stars,
+ [Summary("When it was published")]
+ DateTime PublishedAt,
+ [Summary("When what it is published under last changed")]
+ DateTime Updated,
+ [Summary("Where it is read on this site: `/source/{publicKey}`")]
+ string Path
+)
{
public static SourceSettingsResponse Of(SourceSettings settings) => new(
@@ -161,12 +221,12 @@ public sealed record SourceSettingsResponse(bool Published, LicenseResponse Lice
}
-///
-/// What a lambda's source is to be published under. What is left out stays as
-/// it is; an empty author names nobody.
-///
-/// An SPDX identifier from the licenses on offer - MIT when a source is first published without one
-/// Who holds the copyright, as the license is to name them
-public sealed record SourceRequest(string? License, string? Author);
+[Summary("What a lambda's source is to be published under. What is left out stays as it is.")]
+public sealed record SourceRequest(
+ [Summary("The SPDX identifier of one of the licenses on offer; `MIT` when a source is first published without one")]
+ string? License,
+ [Summary("Who holds the copyright, as the license is to name them, on one line; empty names nobody")]
+ string? Author
+);
#endregion
diff --git a/src/GenHTTP.Lambda/Api/Model/Telemetry.cs b/src/GenHTTP.Lambda/Api/Model/Telemetry.cs
index ae04c13c..8d025abc 100644
--- a/src/GenHTTP.Lambda/Api/Model/Telemetry.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Telemetry.cs
@@ -1,47 +1,76 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Meta;
using GenHTTP.Lambda.Services.Telemetry;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// What is running, and on what.
-///
+[Summary("What is running, and on what.")]
public sealed record ServerDescription(
+ [Summary("The engine serving the requests: `ioxide` or `kestrel`")]
string Engine,
+ [Summary("The version of GenHTTP")]
string Version,
+ [Summary("The .NET runtime")]
string Runtime,
+ [Summary("The platform the runtime runs on: `linux-x64`, say")]
string Platform,
+ [Summary("Whether the server garbage collector is used")]
bool ServerGarbageCollection,
+ [Summary("How many processors the process may use")]
int Processors,
+ [Summary("When the server came up")]
DateTime Started,
+ [Summary("How long it has been up, in seconds")]
long UptimeSeconds
);
-///
-/// What the server has answered since it came up.
-///
-public sealed record TrafficDescription(long Requests, long Failed, long Upgrades, int OpenSockets);
+[Summary("What the server has answered since it came up.")]
+public sealed record TrafficDescription(
+ [Summary("Requests answered")]
+ long Requests,
+ [Summary("Of those, answered with a server error")]
+ long Failed,
+ [Summary("Websocket upgrades")]
+ long Upgrades,
+ [Summary("Connections open right now")]
+ int OpenSockets
+);
-///
-/// What the platform is holding.
-///
-public sealed record PlatformDescription(int Lambdas, int Deployed, int Versions);
+[Summary("What the platform holds.")]
+public sealed record PlatformDescription(
+ [Summary("Lambdas there are")]
+ int Lambdas,
+ [Summary("Of those, online")]
+ int Deployed,
+ [Summary("Versions kept, of all lambdas")]
+ int Versions
+);
-///
-/// The telemetry page in one document: what is running, what it has done, and
-/// the readings taken while it did.
-///
+[Summary("The state of the server, and the readings taken while it ran.")]
public sealed record TelemetryResponse(
+ [Summary("What is running, and on what")]
ServerDescription Server,
+ [Summary("What it answered since it came up")]
TrafficDescription Traffic,
+ [Summary("What the platform holds")]
PlatformDescription Platform,
+ [Summary("The newest reading")]
TelemetrySample Latest,
+ [Summary("How often a reading is taken, in seconds")]
int IntervalSeconds,
+ [Summary("The readings of the window asked for, oldest first")]
IReadOnlyList Samples,
+ [Summary("What happened on the platform per day")]
EventHistory Events
);
-///
-/// What every lambda has been doing since the server came up.
-///
-public sealed record ActivityResponse(IReadOnlyList Lambdas, long Requests, long Upgrades);
+[Summary("What every lambda has been doing since the server came up.")]
+public sealed record ActivityResponse(
+ [Summary("Every lambda that was called")]
+ IReadOnlyList Lambdas,
+ [Summary("Their requests, together")]
+ long Requests,
+ [Summary("Their websocket upgrades, together")]
+ long Upgrades
+);
diff --git a/src/GenHTTP.Lambda/Api/Model/Versions.cs b/src/GenHTTP.Lambda/Api/Model/Versions.cs
index 205aa7c6..a6780882 100644
--- a/src/GenHTTP.Lambda/Api/Model/Versions.cs
+++ b/src/GenHTTP.Lambda/Api/Model/Versions.cs
@@ -1,52 +1,75 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Deployment.Model;
namespace GenHTTP.Lambda.Api.Model;
-///
-/// The source to store as the next version, and why it was written.
-///
-/// Every file of the lambda, lambda.cs first
-///
-/// What the user wants from this version and why - their requirements, in their
-/// own words where possible. Optional, and cut past 4000 characters.
-///
-///
-/// What this version changes, in a line. Optional, and cut past 500 characters.
-///
-public sealed record VersionRequest(IReadOnlyList? Files, string? Specification = null, string? Change = null);
+[Summary("Every file of a lambda, to be saved as its next version, and why it was written.")]
+public sealed record VersionRequest(
+ [Summary("Every file of the lambda, `lambda.cs` first")]
+ IReadOnlyList? Files,
+ [Summary("What the user wants from this version and why - their requirements, in their own words where possible. Optional, and cut past 4000 characters.")]
+ string? Specification = null,
+ [Summary("What this version changes, in one line for the owner. Optional, and cut past 500 characters.")]
+ string? Change = null
+);
-///
-/// Changes to apply to the newest version, stored as the next one.
-///
-/// Files to add, or to replace where one of that name exists
-/// Names of files to remove
-/// Replacements within files, each of which has to match exactly once
-/// What the user wants from this change and why. Optional.
-/// What this version changes, in a line. Optional.
-public sealed record VersionChangeRequest(IReadOnlyList? Files, IReadOnlyList? Remove, IReadOnlyList? Edits,
- string? Specification = null, string? Change = null);
+[Summary("Changes to make to some files, saved as the next version or into a feature.")]
+public sealed record VersionChangeRequest(
+ [Summary("Files to add, or to replace where one of that name exists")]
+ IReadOnlyList? Files,
+ [Summary("Names of files to remove. Each has to exist.")]
+ IReadOnlyList? Remove,
+ [Summary("Replacements of text within files, applied after the files were added and removed")]
+ IReadOnlyList? Edits,
+ [Summary("What the user wants from this change and why. Optional, and cut past 4000 characters.")]
+ string? Specification = null,
+ [Summary("What this change does, in one line for the owner. Optional, and cut past 500 characters.")]
+ string? Change = null
+);
-///
-/// One entry of the version history.
-///
-/// What the user wanted and why, where whoever saved it said
-/// What it changed, where whoever saved it said
-/// Where it came from: template, api, agent or system
-public sealed record VersionResponse(int Version, DateTime Created, string? Specification, string? Change, string? Origin);
+[Summary("A version, without its files.")]
+public sealed record VersionResponse(
+ [Summary("Its number, counting from 1")]
+ int Version,
+ [Summary("When it was saved")]
+ DateTime Created,
+ [Summary("What the user wanted and why, where whoever saved it said")]
+ string? Specification,
+ [Summary("What it changed, where whoever saved it said")]
+ string? Change,
+ [Summary("How it was saved: `template` (what the lambda started as), `api`, `agent`, `git`, `admin` or `system`")]
+ string? Origin
+);
-///
-/// A version that was just stored.
-///
-/// What the user wanted and why, as it was kept
-/// What it changes, as it was kept
-/// Where it came from
-/// The outcome of deploying it, if that was asked for
-public sealed record SavedVersionResponse(int Version, DateTime Created, string? Specification, string? Change, string? Origin,
- DeploymentOutcomeResponse? Deployment);
+[Summary("A version that was just saved.")]
+public sealed record SavedVersionResponse(
+ [Summary("Its number")]
+ int Version,
+ [Summary("When it was saved")]
+ DateTime Created,
+ [Summary("What the user wanted and why, as it was kept")]
+ string? Specification,
+ [Summary("What it changes, as it was kept")]
+ string? Change,
+ [Summary("How it was saved")]
+ string? Origin,
+ [Summary("What came of putting it online, where that was asked for (`?deploy=true`); absent otherwise")]
+ DeploymentOutcomeResponse? Deployment
+);
-///
-/// A version including the code it holds.
-///
-/// Every file the version is made of, lambda.cs first
-public sealed record VersionContentResponse(int Version, DateTime Created, string? Specification, string? Change, string? Origin,
- IReadOnlyList Files);
+[Summary("A version with its files.")]
+public sealed record VersionContentResponse(
+ [Summary("Its number")]
+ int Version,
+ [Summary("When it was saved")]
+ DateTime Created,
+ [Summary("What the user wanted and why, where whoever saved it said")]
+ string? Specification,
+ [Summary("What it changed, where whoever saved it said")]
+ string? Change,
+ [Summary("How it was saved: `template`, `api`, `agent`, `git`, `admin` or `system`")]
+ string? Origin,
+ [Summary("Its files, `lambda.cs` first - all of them, or those of the folder asked for")]
+ IReadOnlyList Files
+);
diff --git a/src/GenHTTP.Lambda/Api/MonitoringResource.cs b/src/GenHTTP.Lambda/Api/MonitoringResource.cs
index a14e0834..63c2d57e 100644
--- a/src/GenHTTP.Lambda/Api/MonitoringResource.cs
+++ b/src/GenHTTP.Lambda/Api/MonitoringResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+using GenHTTP.Api.Protocol;
+
using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Configuration;
@@ -16,10 +19,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// How one lambda is doing: its traffic, what it has said, and the whole of
-/// it at a glance.
-///
///
/// The owner's slice of what the operator sees across the installation, and
/// behind the same thing everything else about a lambda is: its editor key.
@@ -27,18 +26,33 @@ namespace GenHTTP.Lambda.Api;
/// than by the public key, because a public key can be given up and claimed
/// again, and what was said under it before belongs to whoever said it.
///
+[Tag(ApiDocumentation.Monitoring)]
+[Summary("How a lambda is doing: the whole of it at a glance, its traffic, and its log.")]
+[Remarks("""
+ `GET …/summary` answers the question *is this all right?* in one call: whether it is online, how it is used,
+ whether it is failing, what it keeps, and how much of its allowances that takes - what an agent asks after deploying
+ to see whether it works.
+
+ Traffic and logs are kept in memory: a restart of the server starts the counting again, and the log is a ring shared
+ with every other lambda, so a busy neighbour shortens how far back it reaches. They are for watching, not keeping;
+ what is counted of visitors over time is under *Analytics*. Neither ever holds a visitor's address.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class MonitoringResource(IMetaService meta, IWorkspaceService workspace, ISecretService secrets, IDataService data,
ITelemetryService telemetry, ILogBook book, LambdaOptions options, ILimitsService limits)
{
#region Functionality
- ///
- /// Whether the lambda is online, how it is being used, whether it is
- /// failing, and how much of its allowance it spends.
- ///
[ResourceMethod("lambdas/:privateKey/summary")]
- public LambdaSummaryResponse Summary(string privateKey)
+ [Summary("Reads the whole of a lambda at a glance")]
+ [Remarks("""
+ Whether it is online and with which version, its traffic over the last hour and day, its last few problems, what
+ its version and its data take, its limits, and what its documentation says. The figures about code and storage
+ are about the version online, or the newest where none is.
+ """)]
+ [return: Summary("The lambda at a glance")]
+ public LambdaSummaryResponse Summary([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var lambda = meta.Require(privateKey);
@@ -105,34 +119,29 @@ [.. problems.Select(Describe)],
);
}
- ///
- /// The traffic of the lambda: the last hour by the minute, the last day by
- /// the quarter hour, how requests were answered and what was asked for.
- ///
- ///
- /// Counted in memory since the server last started, which the answer
- /// says, so a restart starts every figure here again from nothing.
- ///
[ResourceMethod("lambdas/:privateKey/traffic")]
- public LambdaTraffic Traffic(string privateKey)
+ [Summary("Reads the traffic of a lambda")]
+ [Remarks("""
+ The last hour by the minute, the last day by the quarter hour, how requests were answered, what was asked for and
+ at which of its addresses. Counted in memory since the server last started, which `since` says.
+ """)]
+ [return: Summary("Its traffic since the server came up")]
+ public LambdaTraffic Traffic([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> telemetry.DescribeLambda(meta.RequireId(privateKey));
- ///
- /// What the lambda and the server about it have said, oldest first.
- ///
- ///
- /// The last sequence already seen. Omitted, only the tail comes back.
- ///
- /// The lowest level worth returning: debug, info, warn or error
- /// At most this many lines, up to 2000
- ///
- /// Its requests, what it printed, and what the server said while serving
- /// it - a handler that threw, a request that timed out. Held in a ring
- /// shared with everything else on the installation, so a busy neighbour
- /// shortens how far back this reaches; it is for watching, not keeping.
- ///
[ResourceMethod("lambdas/:privateKey/logs")]
- public OwnerLogResponse Logs(string privateKey, long? since, string? level, int? limit)
+ [Summary("Reads the log of a lambda")]
+ [Remarks("""
+ Its requests, what its code printed, and what the server said while serving it - a handler that threw, a request
+ that timed out. Oldest first. To follow it, pass the `cursor` of each answer as `since` of the next; `missed` says
+ how many lines went by unread in between. Requests are logged as warnings when they were answered with a client
+ error and as errors with a server error. The previews of its features have logs of their own.
+ """)]
+ [return: Summary("The lines, and where to carry on from")]
+ public OwnerLogResponse Logs([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("The `cursor` of the previous answer: only what came after it. Left out, the last 500 lines.")] long? since,
+ [Summary("The lowest level to answer with: `debug`, `info` (the default), `warn` or `error`")] string? level,
+ [Summary("At most this many lines, up to 2000; by default 500, or 1000 with `since`")] int? limit)
{
var id = meta.RequireId(privateKey);
diff --git a/src/GenHTTP.Lambda/Api/SecretResource.cs b/src/GenHTTP.Lambda/Api/SecretResource.cs
index 7dcdda50..6b1285d9 100644
--- a/src/GenHTTP.Lambda/Api/SecretResource.cs
+++ b/src/GenHTTP.Lambda/Api/SecretResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Model;
@@ -13,41 +14,52 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The secrets of a lambda - API keys, passwords, tokens - which its code reads
-/// with Secret.Read("NAME") and nobody else reads at all.
-///
///
/// A value is written and never read back: not here, not in the editor, not by
-/// an agent. What can be seen is the name, when it was set, and whether the
-/// code reads it - and which names the code reads that have no value yet.
-/// Secrets are data: every version reads the same, deploying and rolling back
-/// leave them alone, and a feature works on a copy of them. They are off until
-/// they are switched on (PUT /lambdas/{privateKey}/data/secrets), and
-/// switching them off deletes them.
+/// an agent. There is deliberately no operation that answers with one.
///
+[Tag(ApiDocumentation.Secrets)]
+[Summary("The secrets of a lambda - API keys, passwords, tokens - which its code reads and nobody else does.")]
+[Remarks("""
+ The code reads a secret with `Secret.Read("NAME")`, or asks whether it is there with `Secret.Exists("NAME")`. A value
+ is **written and never read back**: no operation of this API answers with one, and neither does the editor. What can
+ be seen is the name, when it was set, whether the code reads it - and which names the code reads that have no value
+ yet (`missing`), which is what somebody still has to enter.
+
+ Secrets are data: every version reads the same, deploying and rolling back leave them alone, and a feature works on a
+ copy of them. They are off until they are switched on (`PUT /lambdas/{privateKey}/data/secrets`), and switching them
+ off deletes them. A lambda may keep 100.
+
+ The values are kept encrypted at rest, and a lambda reads a changed value from its next `Secret.Read` on.
+ An exported project reads them from environment variables of the same names.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class SecretResource(ISecretService secrets, IMetaService meta, IFeatureService features, ILogger logger)
{
#region The lambda's
- ///
- /// The secrets by name, and which names the code reads.
- ///
[ResourceMethod("lambdas/:privateKey/secrets")]
- public SecretListingResponse List(string privateKey)
+ [Summary("Lists the secrets by name")]
+ [Remarks("Never their values. The names the code reads are found in the code of the version online and of the newest one.")]
+ [return: Summary("The secrets by name, and the names the code reads")]
+ public SecretListingResponse List([Summary(ApiDocumentation.PrivateKey)] string privateKey)
=> Describe(secrets.List(privateKey));
- ///
- /// Stores a value under a name, replacing the one there.
- ///
- ///
- /// The lambda reads it from its next call to Secret.Read on, without
- /// being deployed again. Refused while secrets are switched off.
- ///
- /// Letters, digits and underscores, not starting with a digit: STRIPE_KEY
[ResourceMethod(Method.Put, "lambdas/:privateKey/secrets/:name")]
- public SecretResponse Put(string privateKey, string name, SecretRequest request)
+ [Summary("Stores a secret")]
+ [Remarks("""
+ The lambda reads it from its next `Secret.Read` on, without being deployed again. The value cannot be read back
+ afterwards - not even by whoever stored it. Its features keep the copies they have.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The name is not one a secret may have, or the value is empty or larger than 32 KB.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "Secrets are switched off, or the lambda has as many secrets as it may keep.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The secret, without its value")]
+ public SecretResponse Put([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary("Its name, as the code reads it: letters, digits and underscores, not starting with a digit, up to 128 characters - `STRIPE_KEY`. Case sensitive.")] string name,
+ SecretRequest request)
{
var secret = secrets.Set(privateKey, name, request.Value);
@@ -56,11 +68,13 @@ public SecretResponse Put(string privateKey, string name, SecretRequest request)
return Describe(secret);
}
- ///
- /// Removes a secret.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/secrets/:name")]
- public void Delete(string privateKey, string name)
+ [Summary("Deletes a secret")]
+ [Remarks("From its next request on, code that reads it fails, and code that asks `Secret.Exists` is told it is not there.")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no secret of that name.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void Delete([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary("Its name, case sensitive")] string name)
{
secrets.Delete(privateKey, name);
@@ -71,19 +85,25 @@ public void Delete(string privateKey, string name)
#region A feature's copy
- ///
- /// A feature's copy of the secrets, which its preview reads.
- ///
[ResourceMethod("lambdas/:privateKey/features/:feature/secrets")]
- public SecretListingResponse ListOfFeature(string privateKey, string feature)
+ [Summary("Lists a feature's copy of the secrets")]
+ [Remarks("The copy its preview reads. The names the code reads are found in the feature's files.")]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoFeature, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The feature's secrets by name, and the names its code reads")]
+ public SecretListingResponse ListOfFeature([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature)
=> Describe(secrets.List(privateKey, feature));
- ///
- /// Stores a value in a feature's copy, for trying the feature with - a
- /// key for a sandbox, say. The lambda's own is not touched.
- ///
[ResourceMethod(Method.Put, "lambdas/:privateKey/features/:feature/secrets/:name")]
- public SecretResponse PutOfFeature(string privateKey, string feature, string name, SecretRequest request)
+ [Summary("Stores a secret in a feature's copy")]
+ [Remarks("For trying the feature with - a key for a sandbox, say. The lambda's own secrets are not touched, and the copy is thrown away when the feature is merged.")]
+ [Response(ResponseStatus.BadRequest, "The name is not one a secret may have, or the value is empty or larger than 32 KB.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Conflict, "Secrets are switched off, or the copy has as many secrets as a lambda may keep.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, ApiDocumentation.NoFeature, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The secret, without its value")]
+ public SecretResponse PutOfFeature([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Its name, as the code reads it: letters, digits and underscores, not starting with a digit. Case sensitive.")] string name,
+ SecretRequest request)
{
var secret = secrets.Set(privateKey, name, request.Value, feature);
@@ -93,11 +113,13 @@ public SecretResponse PutOfFeature(string privateKey, string feature, string nam
return Describe(secret);
}
- ///
- /// Removes a secret from a feature's copy.
- ///
[ResourceMethod(Method.Delete, "lambdas/:privateKey/features/:feature/secrets/:name")]
- public void DeleteOfFeature(string privateKey, string feature, string name)
+ [Summary("Deletes a secret from a feature's copy")]
+ [Response(ResponseStatus.NoContent, ApiDocumentation.Deleted)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, it has no such feature, or the copy has no secret of that name.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public void DeleteOfFeature([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.FeatureKey)] string feature,
+ [Summary("Its name, case sensitive")] string name)
{
secrets.Delete(privateKey, name, feature);
diff --git a/src/GenHTTP.Lambda/Api/ShowcaseResource.cs b/src/GenHTTP.Lambda/Api/ShowcaseResource.cs
index 9911e841..6762fd24 100644
--- a/src/GenHTTP.Lambda/Api/ShowcaseResource.cs
+++ b/src/GenHTTP.Lambda/Api/ShowcaseResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -18,20 +19,25 @@ namespace GenHTTP.Lambda.Api;
/// Public and read only, like the examples: it names lambdas by their public
/// key and never hands out an editor key. Only lambdas that are online are
/// listed - an entry for something that does not answer is an advertisement
-/// for a broken link.
+/// for a broken link. Listed under the tag
+/// describes.
///
+[Tag(ApiDocumentation.Showcase)]
public sealed class ShowcaseResource(IShowcaseService showcases, ILambdaAddresses addresses)
{
public const int PageSize = 12;
- ///
- /// One page of the showcase, the most active lambdas first.
- ///
- /// How many to leave out, from the start
- /// How many to answer with, at most 48
[ResourceMethod]
- public Page List(int skip = 0, int take = PageSize)
+ [Summary("Lists the showcase, a page at a time")]
+ [Remarks("""
+ Public. Only lambdas that are online are listed, the liveliest first: what was used over the last day, how recently
+ it was visited and changed, and a few days of being seen for a new entry. The order changes as they are used, so a
+ page read later may overlap the one before.
+ """)]
+ [return: Summary("A page of entries, and where the next one starts")]
+ public Page List([Summary("How many entries to leave out, from the start")] int skip = 0,
+ [Summary("How many entries to answer with, 1 to 48")] int take = PageSize)
{
var from = Math.Max(0, skip);
@@ -40,15 +46,17 @@ public Page List(int skip = 0, int take = PageSize)
return Page.Of([.. page.Entries.Select(e => ShowcaseResponse.Of(e, addresses))], from, page.Total);
}
- ///
- /// The picture of an entry.
- ///
///
/// Cached for good, because the address it is linked with carries the time
/// the entry last changed: a new picture is a new address.
///
[ResourceMethod(":publicKey/image")]
- public IResponse Image(string publicKey, IRequest request)
+ [Summary("Reads the picture of a showcase entry")]
+ [Remarks("Public. Link it by the `imagePath` of the entry, which changes with the picture, so it may be cached for good.")]
+ [Response(ResponseStatus.Ok, "The picture, as it was uploaded", ContentType = "image/*")]
+ [ResponseHeader(ResponseStatus.Ok, "Cache-Control", "`public, max-age=31536000, immutable`")]
+ [Response(ResponseStatus.NotFound, "The lambda of this public key has no showcase entry.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public IResponse Image([Summary("The public key of the lambda")] string publicKey, IRequest request)
{
var image = showcases.GetImage(publicKey)
?? throw LambdaException.NotFound($"There is no showcase for '{publicKey}'.");
diff --git a/src/GenHTTP.Lambda/Api/SourceResource.cs b/src/GenHTTP.Lambda/Api/SourceResource.cs
index f30c6165..705923b9 100644
--- a/src/GenHTTP.Lambda/Api/SourceResource.cs
+++ b/src/GenHTTP.Lambda/Api/SourceResource.cs
@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Text;
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -28,8 +29,10 @@ namespace GenHTTP.Lambda.Api;
///
/// What is read is a version packed into a project, the export without the
/// data: the files come out of the zip it was packed into once, never out of
-/// the lambda's database, workspace or secrets.
+/// the lambda's database, workspace or secrets. Listed under the tag
+/// describes.
///
+[Tag(ApiDocumentation.OpenSource)]
public sealed class SourceResource(ISourceService sources, ILambdaAddresses addresses, ILogger logger)
{
@@ -58,15 +61,14 @@ public sealed class SourceResource(ISourceService sources, ILambdaAddresses addr
#region Listing
- ///
- /// One page of the published sources.
- ///
- /// Words the key, the title or what it is about have to contain
- /// stars (the default), updated or published
- /// How many to leave out, from the start
- /// How many to answer with, at most 48
[ResourceMethod]
- public Page List(string? search, string? order, int skip = 0, int take = PageSize)
+ [Summary("Lists the published sources, a page at a time")]
+ [Remarks("Public. Every lambda whose owner published its code, whether or not it is online.")]
+ [return: Summary("A page of sources, and where the next one starts")]
+ public Page List([Summary("Words that the key, the title or what it is about all have to contain")] string? search,
+ [Summary("`stars` (the default), the most starred first; `updated`, the most recently saved first; or `published`, the most recently published first")] string? order,
+ [Summary("How many sources to leave out, from the start")] int skip = 0,
+ [Summary("How many sources to answer with, 1 to 48")] int take = PageSize)
{
var from = Math.Max(0, skip);
@@ -82,16 +84,13 @@ public Page List(string? search, string? order, int skip =
return Page.Of([.. page.Entries.Select(e => SourceEntryResponse.Of(e, addresses))], from, page.Total);
}
- ///
- /// A published source: what it is, under which license, where it runs,
- /// and every version it went through.
- ///
- ///
- /// Answered with a ticket to star it with, which is the one thing here that
- /// changes anything.
- ///
[ResourceMethod(":publicKey")]
- public SourceProjectResponse Get(string publicKey)
+ [Summary("Reads a published source")]
+ [Remarks("What it is, under which license, where it runs, every version kept and what each changed - and a `starTicket` to star it with.")]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key - it does not exist, or its owner has not published it, which are answered alike.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The source, its versions and a ticket to star it with")]
+ public SourceProjectResponse Get([Summary("The public key of the lambda")] string publicKey)
{
var project = sources.GetProject(publicKey) ?? throw NotPublished(publicKey);
@@ -109,16 +108,15 @@ public SourceProjectResponse Get(string publicKey)
#region Versions
- ///
- /// The files of a version, as the project it is packed into has them.
- ///
- ///
- /// Packed on the first request for the version and kept, so the first
- /// answer may take a moment for a large lambda and every later one does
- /// not.
- ///
[ResourceMethod(":publicKey/versions/:version")]
- public async ValueTask Tree(string publicKey, int version)
+ [Summary("Lists the files of a published version")]
+ [Remarks("""
+ As the project it is packed into has them - the export of that version, without its data. Packed on the first
+ request for the version and kept, so the first answer may take a moment for a large lambda.
+ """)]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key, or it has no such version.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The files of the version")]
+ public async ValueTask Tree([Summary("The public key of the lambda")] string publicKey, [Summary(ApiDocumentation.VersionNumber)] int version)
{
var archive = await RequireAsync(publicKey, version);
@@ -131,13 +129,14 @@ public async ValueTask Tree(string publicKey, int version)
return new SourceTreeResponse(publicKey, version, archive.Root, new FileInfo(archive.File).Length, files, ZipPath(publicKey, version));
}
- ///
- /// One file of a version: its text, while it is text and no longer than a
- /// megabyte, and where to fetch it as it is either way.
- ///
- /// Its path below the project's folder, encoded as one segment ("docs%2Fproduct.md")
[ResourceMethod(":publicKey/versions/:version/files/:path")]
- public async ValueTask File(string publicKey, int version, string path)
+ [Summary("Reads a file of a published version")]
+ [Remarks("Its text, while it is UTF-8 text no longer than a megabyte, and either way where to fetch it as it is (`rawPath`).")]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key, it has no such version, or the version has no such file.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The file, with its text where it is text")]
+ public async ValueTask File([Summary("The public key of the lambda")] string publicKey, [Summary(ApiDocumentation.VersionNumber)] int version,
+ [Summary("Its path below the project's folder, encoded as one segment: `docs%2Fproduct.md`")] string path)
{
var archive = await RequireAsync(publicKey, version);
@@ -158,20 +157,26 @@ public async ValueTask File(string publicKey, int ver
text, raw);
}
- ///
- /// One file of a version as it is: a picture as a picture, anything else
- /// as text or bytes - never as something a browser would run.
- ///
///
/// Served from the origin of the site, so a page or a script a lambda
/// ships is sent as text here, and whatever is sent is sandboxed and never
/// sniffed. That is what lets the documentation show the pictures beside
/// it without letting a published source run anything on this site.
///
- /// Its path below the project's folder, encoded as one segment
- /// Anything but empty: sent to be saved rather than shown
[ResourceMethod(":publicKey/versions/:version/raw/:path")]
- public async ValueTask Raw(string publicKey, int version, string path, string? download, IRequest request)
+ [Summary("Fetches a file of a published version as it is")]
+ [Remarks("""
+ A picture as a picture, anything else as plain text or as bytes - never as something a browser would run: a page
+ or a script is sent as text, under a sandboxing content security policy. Bytes, and anything asked for with
+ `download`, are sent to be saved.
+ """)]
+ [Response(ResponseStatus.Ok, "The file: a picture, `text/plain` or `application/octet-stream`", ContentType = "*/*")]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, with the name of the file - where it is bytes or `download` was asked for")]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key, it has no such version, or the version has no such file.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask Raw([Summary("The public key of the lambda")] string publicKey, [Summary(ApiDocumentation.VersionNumber)] int version,
+ [Summary("Its path below the project's folder, encoded as one segment")] string path,
+ [Summary("Anything but empty sends it to be saved rather than shown: `?download=1`")] string? download, IRequest request)
{
var archive = await RequireAsync(publicKey, version);
@@ -207,13 +212,13 @@ public async ValueTask Raw(string publicKey, int version, string path
return response.Build();
}
- ///
- /// A version as a project to download: a .NET 10 project with a
- /// Dockerfile, its documentation and tests, and its license - and none of
- /// the data the lambda keeps.
- ///
[ResourceMethod(":publicKey/versions/:version/zip")]
- public async ValueTask Zip(string publicKey, int version, IRequest request)
+ [Summary("Downloads a published version as a project")]
+ [Remarks("A .NET 10 project with a `Dockerfile`, its documentation and tests, and its `LICENSE` - and none of the data the lambda keeps.")]
+ [Response(ResponseStatus.Ok, "The project, as a zip archive", ContentType = ApiDocumentation.Zip)]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, named after the public key and the version: `quiz-v3.zip`")]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key, or it has no such version.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask Zip([Summary("The public key of the lambda")] string publicKey, [Summary(ApiDocumentation.VersionNumber)] int version, IRequest request)
{
var archive = await RequireAsync(publicKey, version);
@@ -230,16 +235,25 @@ public async ValueTask Zip(string publicKey, int version, IRequest re
#region Stars
- ///
- /// Stars a published source, or takes the star back.
- ///
///
/// A POST with the ticket the source was read with, not a link: something
- /// that follows every link it finds stars nothing. Each address stars a
- /// source once, and only so many in a while.
+ /// that follows every link it finds stars nothing.
///
[ResourceMethod(Method.Post, ":publicKey/star")]
- public StarResponse Star(string publicKey, IRequest request, StarRequest body)
+ [Summary("Stars a published source, or takes the star back")]
+ [Remarks("""
+ With the `starTicket` the source was read with, which is good from a second after it was handed out for a day.
+ Each address stars a source once - starring it again changes nothing, and `counted` says so - and an address may
+ star only so many sources in a while.
+ """)]
+ [Response(ResponseStatus.BadRequest, "The ticket is missing, too fresh or too old. Read the source again and star it then.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.TooManyRequests, "This address starred a lot in a short while. Try again in a few minutes.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "There is no published source at this public key - it does not exist, or its owner has not published it, which are answered alike.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("How it is starred now")]
+ public StarResponse Star([Summary("The public key of the lambda")] string publicKey, IRequest request, StarRequest body)
{
var outcome = sources.Star(publicKey, body?.Ticket, request.Client.Address, body?.Starred ?? true)
?? throw NotPublished(publicKey);
diff --git a/src/GenHTTP.Lambda/Api/SystemResource.cs b/src/GenHTTP.Lambda/Api/SystemResource.cs
index ba671d56..1af3e75e 100644
--- a/src/GenHTTP.Lambda/Api/SystemResource.cs
+++ b/src/GenHTTP.Lambda/Api/SystemResource.cs
@@ -1,3 +1,6 @@
+using GenHTTP.Api.Content.Services;
+
+using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Lambda.Services.Building;
using GenHTTP.Lambda.Services.Deployment.Compilation;
@@ -9,10 +12,9 @@
namespace GenHTTP.Lambda.Api;
-///
-/// What the editor needs to know about the platform: the terms, the example it
-/// starts from, the vocabulary it can suggest and whether it can build things.
-///
+[Tag(ApiDocumentation.Platform)]
+[Summary("What this installation is like: its terms, its limits, what a lambda can start from, and where lambdas answer.")]
+[Remarks("Public. Read it first: the limits and the address of lambdas differ between installations, and may change.")]
public sealed class SystemResource(ILimitsService limits, IBuildService builds, ISettingsService settings, ILambdaAddresses addresses)
{
@@ -27,10 +29,14 @@ Lambdas run on shared infrastructure. By creating one you agree not to deploy
may be removed at any time.
""";
- ///
- /// The terms, limits and editor vocabulary of this installation.
- ///
[ResourceMethod]
+ [Summary("Describes this installation")]
+ [Remarks("""
+ Its terms, which creating a lambda accepts; the limits of a free lambda; what a new lambda can start from; whether
+ the build agent is on offer; where a lambda answers (`lambdaUrl`); and what the editor suggests while code is
+ written - the namespaces a lambda has imported and the types and snippets it may use.
+ """)]
+ [return: Summary("The terms, limits, starters and vocabulary of this installation")]
public PlatformResponse Get()
{
var current = limits.Get();
@@ -48,14 +54,14 @@ public PlatformResponse Get()
);
}
- ///
- /// Which pages the site links to, as the operator switched them.
- ///
///
/// Apart from the platform description, which carries the whole editor
/// vocabulary and is too much to ask for on every page.
///
[ResourceMethod("features")]
+ [Summary("Reads which pages the site links to")]
+ [Remarks("What the site asks on every page. Unrelated to the features of a lambda.")]
+ [return: Summary("Which pages are linked")]
public FeaturesResponse GetFeatures()
{
var current = settings.Get();
diff --git a/src/GenHTTP.Lambda/Api/TelemetryResource.cs b/src/GenHTTP.Lambda/Api/TelemetryResource.cs
index 3a8e77b5..a25c0c44 100644
--- a/src/GenHTTP.Lambda/Api/TelemetryResource.cs
+++ b/src/GenHTTP.Lambda/Api/TelemetryResource.cs
@@ -1,6 +1,8 @@
using System.Runtime;
using System.Runtime.InteropServices;
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Api.Infrastructure;
using GenHTTP.Lambda.Api.Model;
using GenHTTP.Api.Protocol;
@@ -22,16 +24,23 @@ namespace GenHTTP.Lambda.Api;
/// the owner of a lambda sees its own figures in the editor, and nobody sees
/// everybody's.
///
+[Tag(ApiDocumentation.Telemetry)]
+[Summary("What the server process looks like from the inside, so a long run can be watched rather than guessed at.")]
+[Remarks("""
+ Readings of memory, the collector, the threads and the connections, taken on an interval and kept in memory, and what
+ happened on the platform per day. Only the public key identifies a lambda here.
+ """)]
+[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)]
+[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)]
+[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)]
public sealed class TelemetryResource(ITelemetryService telemetry, IMetaService meta, LambdaOptions options)
{
- ///
- /// The current state of the server and the readings of the last hour.
- ///
- /// How far back the readings should reach
- /// How far back the business events should reach
[ResourceMethod]
- public TelemetryResponse Get(IRequest request, int minutes = 60, int days = 30)
+ [Summary("Reads the telemetry of the server")]
+ [return: Summary("What is running, what it did, and the readings taken while it did")]
+ public TelemetryResponse Get(IRequest request, [Summary("How far back the readings reach, in minutes: 1 to 1440, as far as they are kept")] int minutes = 60,
+ [Summary("How far back the events reach, in days: 1 to 365")] int days = 30)
{
var window = TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 60 * 24));
@@ -73,14 +82,10 @@ public TelemetryResponse Get(IRequest request, int minutes = 60, int days = 30)
);
}
- ///
- /// What each lambda has been doing since the server came up.
- ///
- ///
- /// Only the public key identifies a lambda here - the editor key, the code
- /// and the visitors are not part of it.
- ///
[ResourceMethod("lambdas")]
+ [Summary("Reads what each lambda has been doing")]
+ [Remarks("Only the public key identifies a lambda here - the editor key, the code and the visitors are not part of it. A lambda nobody called is not listed.")]
+ [return: Summary("Every lambda that was called, and the totals")]
public ActivityResponse GetLambdas()
{
var activity = telemetry.DescribeLambdas();
diff --git a/src/GenHTTP.Lambda/Api/VersionResource.cs b/src/GenHTTP.Lambda/Api/VersionResource.cs
index d65d76c8..881ac01c 100644
--- a/src/GenHTTP.Lambda/Api/VersionResource.cs
+++ b/src/GenHTTP.Lambda/Api/VersionResource.cs
@@ -1,3 +1,4 @@
+using GenHTTP.Api.Content.Services;
using GenHTTP.Api.Protocol;
using GenHTTP.Lambda.Api.Infrastructure;
@@ -16,9 +17,6 @@
namespace GenHTTP.Lambda.Api;
-///
-/// The saved versions of a lambda's code.
-///
///
/// Versions are never changed once they are stored and never removed one by
/// one, so there is nothing to put or delete here: a change to the code is a
@@ -27,46 +25,62 @@ namespace GenHTTP.Lambda.Api;
/// Every way of storing one takes an optional specification and change: what
/// the user wanted and what was done about it, which the code alone cannot say.
///
+[Tag(ApiDocumentation.Versions)]
+[Summary("The saved versions of a lambda: its program, which never changes once it is saved.")]
+[Remarks("""
+ A version is the program: its code and its resources, and what is written about it (`docs/`, `tests/`). It is
+ numbered from 1 and never changes; a change to the lambda is a new version. Saving one does not put it online -
+ pass `?deploy=true`, or deploy it afterwards. Data (database, workspace, secrets) is not part of a version.
+
+ Every save may say what the user wanted (`specification`) and what it changes (`change`), which the history shows.
+ Only so many versions are kept, as the tier of the lambda allows; with every save the oldest beyond that go, but
+ never the version online or one a feature is based on.
+
+ A lambda that exists is best changed in a feature (see *Features*) rather than by saving versions directly: the
+ feature can be tried at its own address first.
+ """)]
+[Response(ResponseStatus.NotFound, ApiDocumentation.NoLambda, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
public sealed class VersionResource(IMetaService meta, ILimitsService limits, ILogger logger)
{
- ///
- /// Lists the stored versions, newest first.
- ///
[ResourceMethod("lambdas/:privateKey/versions")]
- public List List(string privateKey)
+ [Summary("Lists the versions that are kept, newest first")]
+ [Remarks("Without their files. `GET …/versions/{version}` reads one with them.")]
+ [return: Summary("The versions, newest first")]
+ public List List([Summary(ApiDocumentation.PrivateKey)] string privateKey)
{
var versions = meta.GetVersions(privateKey);
return versions.Select(Describe).ToList();
}
- ///
- /// Reads the files of a single version.
- ///
- ///
- /// Every file unless a folder is named: its documentation is
- /// ?folder=docs/ and its tests ?folder=tests/, which is how
- /// they are read without every resource of the version coming along.
- ///
- /// Only the files below this folder, such as docs/ or resources/
[ResourceMethod("lambdas/:privateKey/versions/:version")]
- public VersionContentResponse Get(string privateKey, int version, string? folder)
+ [Summary("Reads a version with its files")]
+ [Remarks("""
+ Every file, `lambda.cs` first, unless a `folder` is named: `?folder=docs/` reads the documentation of the version
+ and `?folder=tests/` its tests, without every resource coming along.
+ """)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no such version - it never existed, or it was not kept.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [return: Summary("The version with its files")]
+ public VersionContentResponse Get([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.VersionNumber)] int version,
+ [Summary("Only the files whose name starts with this, such as `docs/` or `resources/`")] string? folder)
=> Describe(meta.GetVersion(privateKey, version), folder);
- ///
- /// Downloads the files of a single version as a zip archive.
- ///
- ///
- /// The archive holds the files as they are named in the lambda, so it can
- /// be changed locally and uploaded again as a new version. With
- /// ?layout=project it holds them where a clone has them instead -
- /// the snippet as Project.cs and the other C# files named the .NET
- /// way - so a .NET tool set up in a clone finds them where it looks.
- ///
- /// lambda (the default) or project
[ResourceMethod("lambdas/:privateKey/versions/:version/zip")]
- public IResponse GetArchive(string privateKey, int version, string? layout, IRequest request)
+ [Summary("Downloads a version as a zip archive")]
+ [Remarks("""
+ The archive holds the files as the lambda names them, so it can be changed locally and saved again as a new version
+ with `POST …/versions/zip`. With `?layout=project` it holds them where a clone of the lambda's git repository has
+ them instead, so a .NET tool set up in a clone finds them where it looks. Unlike the export, it holds no project
+ around them and no data.
+ """)]
+ [Response(ResponseStatus.Ok, "The files, as a zip archive", ContentType = ApiDocumentation.Zip)]
+ [ResponseHeader(ResponseStatus.Ok, "Content-Disposition", "`attachment`, named after the public key and the version: `quiz-v3.zip`, or `quiz-v3-project.zip`")]
+ [Response(ResponseStatus.BadRequest, "There is no such layout.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.NotFound, "No lambda has this editor key, or it has no such version.", Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public IResponse GetArchive([Summary(ApiDocumentation.PrivateKey)] string privateKey, [Summary(ApiDocumentation.VersionNumber)] int version,
+ [Summary(ApiDocumentation.Layout)] string? layout, IRequest request)
{
var laid = LayoutOf(layout);
@@ -84,28 +98,30 @@ public IResponse GetArchive(string privateKey, int version, string? layout, IReq
.Build();
}
- ///
- /// Stores the files of a zip archive as a new version.
- ///
- ///
- /// The archive replaces the whole set of files, so it has to hold all of
- /// them - a file left out is gone from the new version. It is read as a
- /// commit of the files would be: a git repository is skipped, and so is
- /// what the .gitignore files of the code's folders leave out, and
- /// what a clone's own leaves out at the top (bin/, obj/).
- /// Other dot files of the code are kept; hidden files among the resources
- /// are skipped. A single top level folder is removed. With
- /// ?layout=project, the archive is laid out as a clone and the
- /// platform's files are left out. The archive is counted as it is sent,
- /// before anything is left out, so it holds what a commit would - never
- /// what a build installed.
- ///
- /// Whether to put the new version online as well
- /// What the user wants from this version and why
- /// What this version changes, in a line
- /// lambda (the default) or project
[ResourceMethod(Method.Post, "lambdas/:privateKey/versions/zip")]
- public async ValueTask> CreateFromArchive(string privateKey, bool? deploy, string? specification, string? change, string? layout,
+ [Summary("Saves a zip archive as a new version")]
+ [Remarks("""
+ The archive replaces every file, so it has to hold all of them: a file it leaves out is not in the new version.
+
+ It is read as a commit of the files would be. A single folder around everything is removed. A git repository is
+ skipped, and so is what the `.gitignore` files in the archive leave out - except files the newest version already
+ has, which are kept as git keeps what it tracks. Other dot files of the code are kept; hidden files among the
+ resources are skipped. Text files are kept as text, anything that is not UTF-8 as base64. With `?layout=project`
+ the archive is read as a clone of the lambda's git repository has it, and the platform's own files are left out.
+
+ The archive is counted as it is sent, before anything is left out, against what a version may come to. Zip what a
+ commit would hold - never what a build installed (`node_modules`, for example).
+ """)]
+ [RequestBody("A zip archive of every file of the lambda", ContentType = ApiDocumentation.Zip)]
+ [Response(ResponseStatus.Created, "The version was saved. If it was to be deployed as well, `deployment` says whether that worked.")]
+ [Response(ResponseStatus.BadRequest, "The body is not a zip archive, it is larger than a version may be, it has no `lambda.cs`, or a file in it cannot be part of a lambda. The message says which.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> CreateFromArchive([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary(ApiDocumentation.Deploy)] bool? deploy,
+ [Summary(ApiDocumentation.Specification)] string? specification,
+ [Summary(ApiDocumentation.Change)] string? change,
+ [Summary(ApiDocumentation.Layout)] string? layout,
Stream body)
{
var laid = LayoutOf(layout);
@@ -121,28 +137,43 @@ public async ValueTask> CreateFromArchive(string pr
return await SaveAsync(privateKey, files, deploy, specification, change);
}
- ///
- /// Applies changes to the newest version and stores the result as a new one.
- ///
- ///
- /// Files that are not named stay as they are, so a small change does not
- /// need every file to be sent again.
- ///
- /// Whether to put the new version online as well
[ResourceMethod(Method.Post, "lambdas/:privateKey/versions/changes")]
- public async ValueTask> Change(string privateKey, bool? deploy, VersionChangeRequest request)
+ [Summary("Saves changes to some files as a new version")]
+ [Remarks("""
+ Files that are not named stay as they are, so a small change does not send every file again. The changes are
+ applied in this order: `remove` takes files out, `files` adds files or replaces those of the same name, and `edits`
+ replaces text within files - each `find` has to occur exactly once in its file, or nothing is saved. What results
+ has to be a valid lambda, as for any save.
+ """)]
+ [Response(ResponseStatus.Created, "The version was saved. If it was to be deployed as well, `deployment` says whether that worked.")]
+ [Response(ResponseStatus.BadRequest, "Nothing to change, a file to remove or edit that does not exist, a `find` that occurs never or more than once, or a result that cannot be a lambda. Nothing was saved.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> Change([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary(ApiDocumentation.Deploy)] bool? deploy, VersionChangeRequest request)
{
var files = LambdaChanges.Apply(Latest(meta, privateKey), request.Files, request.Remove, request.Edits);
return await SaveAsync(privateKey, files, deploy, request.Specification, request.Change);
}
- ///
- /// Stores the code as a new version.
- ///
- /// Whether to put the new version online as well
[ResourceMethod(Method.Post, "lambdas/:privateKey/versions")]
- public async ValueTask> Create(string privateKey, bool? deploy, VersionRequest request)
+ [Summary("Saves every file of the lambda as a new version")]
+ [Remarks("""
+ `files` is the whole lambda, `lambda.cs` first: a file the newest version has and this request does not is not in
+ the new version. To change a few files, `POST …/versions/changes` sends only those.
+
+ A file name may be refused - a C# file has to be named as C# names its files, a resource needs an extension, and
+ no name may hold spaces or be one Windows reserves; the message says what is wrong with which. The files together
+ may not come to more than the tier of the lambda allows a version (see the limits). Whether the code compiles is
+ not checked unless it is deployed: `POST …/code/check` compiles it without saving.
+ """)]
+ [Response(ResponseStatus.Created, "The version was saved. If it was to be deployed as well, `deployment` says whether that worked.")]
+ [Response(ResponseStatus.BadRequest, "There are no files, `lambda.cs` is not the first, a name or an encoding cannot be used, or the files are larger than a version may be. Nothing was saved.",
+ Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ [Response(ResponseStatus.Forbidden, ApiDocumentation.Demo, Type = typeof(ErrorResponse), ContentType = ApiDocumentation.Json)]
+ public async ValueTask> Create([Summary(ApiDocumentation.PrivateKey)] string privateKey,
+ [Summary(ApiDocumentation.Deploy)] bool? deploy, VersionRequest request)
=> await SaveAsync(privateKey, request.Files, deploy, request.Specification, request.Change);
///
diff --git a/src/GenHTTP.Lambda/GenHTTP.Lambda.csproj b/src/GenHTTP.Lambda/GenHTTP.Lambda.csproj
index 11d47539..80d32c95 100644
--- a/src/GenHTTP.Lambda/GenHTTP.Lambda.csproj
+++ b/src/GenHTTP.Lambda/GenHTTP.Lambda.csproj
@@ -31,8 +31,8 @@
+ referenced in its own right, since GenHTTP.Full.Ioxide does not
+ carry it (it did by way of GenHTTP.Testing, until 11.1) -->
@@ -79,6 +79,8 @@
+
+
diff --git a/src/GenHTTP.Lambda/Resources/Api/Introduction.md b/src/GenHTTP.Lambda/Resources/Api/Introduction.md
new file mode 100644
index 00000000..014c89bf
--- /dev/null
+++ b/src/GenHTTP.Lambda/Resources/Api/Introduction.md
@@ -0,0 +1,160 @@
+GenHTTP Lambda hosts small C# web applications. A **lambda** is a snippet of C#
+that returns a [GenHTTP](https://genhttp.org/) handler - a page, an API, a
+website, a websocket - plus whatever other files it needs. The platform compiles
+it and serves it at an address of its own, `https://{publicKey}.{hosting domain}/`.
+This API is everything the editor does: create a lambda, save its versions, put
+one online, look at its data, its traffic and its logs.
+
+## Getting started
+
+```bash
+# 1. create a lambda - keep its privateKey,
+# which cannot be recovered
+curl -X POST {server}/lambdas \
+ -H 'Content-Type: application/json' \
+ -d '{ "acceptedTerms": true }'
+
+# 2. save its code as a version, and put
+# it online in the same call
+curl -X POST \
+ '{server}/lambdas/{privateKey}/versions?deploy=true' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "files": [ {
+ "name": "lambda.cs",
+ "code": "return Inline.Create().Get(() => \"Hi\");"
+ } ],
+ "change": "Says hi"
+ }'
+
+# 3. it answers at the address the answer
+# names (deployment.lambda.address)
+```
+
+The terms that step 1 accepts are in `GET /system`, along with this
+installation's limits and the address template (`lambdaUrl`). The demos
+(`GET /demos`) are finished lambdas to read, and to start from: pass a demo's
+`id` as the `template` when creating a lambda.
+
+## Who may do what
+
+There are no accounts. A lambda has two keys:
+
+- The **public key** names its address. It is not a secret, and its owner may
+ change it.
+- The **private key** (the *editor key*) is in the path of every call that reads
+ or changes the lambda: `/lambdas/{privateKey}/…`. Holding it is the
+ authorization. Whoever has it may change, deploy and delete the lambda, so treat
+ it like a password: do not log it or put it in a URL a third party sees. It is
+ handed out once, to whoever creates the lambda, and cannot be recovered.
+
+A key that names no lambda is answered with `404`, the same way as one whose
+lambda was deleted. The demos' editor keys are public. A demo can be read like
+any other lambda, but every change is refused with `403`.
+
+The operations under *Operating the installation* are for whoever runs the
+server. They take the token of the installation in the `X-Admin-Token` header:
+`401` without it, `403` with a wrong one. An installation without a token does not
+have them at all (`404`).
+
+## What a lambda is made of
+
+- A **version** is the program: its code and its resources. It never changes
+ once it is saved; a change is a new version. Saving one does not put it online,
+ deploying does (`?deploy=true` on a save, or `POST …/deployment/start`).
+ A new lambda starts with version 1, which is the template it was made from,
+ and nothing is online until a version is deployed. Only so many versions are
+ kept (see the limits); the oldest go first, but never the version that is
+ online or one a feature is based on.
+- The **data** is what the program keeps: its **database** (SQLite), its
+ **workspace** (files it reads and writes) and its **secrets** (API keys,
+ passwords). Data belongs to the lambda and is shared by every version.
+ Deploying, rolling back and merging never touch it. Each kind of data is
+ switched on before the lambda can use it (`PUT …/data/{kind}`). Switching a
+ kind off deletes everything it held.
+- A **feature** is a change worked on beside the lambda. It starts as a copy of
+ a version and of the data, is saved in place as often as needed, has a preview
+ at its own address, and becomes the next version when it is merged. Only a
+ feature based on the newest version can be merged.
+
+## Files
+
+A version is sent and read as a list of files:
+
+```json
+{ "name": "lambda.cs", "code": "return Inline.Create()…;" }
+{ "name": "resources/web/logo.png", "code": "iVBORw0KGgo…", "encoding": "base64" }
+```
+
+- `lambda.cs` comes first. It is the snippet that returns the handler.
+- Every other `.cs` file, in any folder, is compiled with it.
+- Files below `resources/` are the **resources**: what the lambda reads and
+ serves while it runs (`Resources.App("web")` serves `resources/web/`).
+- Every other file - `docs/product.md`, `tests/README.md`, the sources of a front
+ end - is kept with the version and never compiled or served.
+- Text is sent as it is. Anything else is sent base64 encoded with
+ `"encoding": "base64"`. C# files are always text.
+- Names are case sensitive, but two names that differ only by their case are
+ refused, as are names that would not survive a clone or a zip on Windows. A
+ refused save says which name it refused and why.
+
+A save that only changes some files does not have to send all of them:
+`POST …/versions/changes` and `POST …/features/{feature}/changes` add, replace and
+remove files, or replace text inside them.
+
+## Limits
+
+What a version may come to (its code and resources together), the room its data
+may take (database and workspace together), and how many versions and features
+are kept are set by the operator of the installation, per tier, and may change.
+Read them instead of assuming them: `GET /system` for a new lambda, and the
+`limits` of `GET /lambdas/{privateKey}/summary` for an existing one. A save or
+an upload that does not fit is refused with `400`, and a feature or a secret
+more than a lambda may have with `409`. The message names the limit either
+way.
+
+The tier of a lambda (`Free`, `Premium` or `Demo`) is assigned by the operator.
+A free lambda that nobody visits or edits for a while is taken offline, and
+later removed. `deployedUntil` and `keptUntil` say when; both move forward with
+every visit and every edit.
+
+## Answers and errors
+
+Requests and answers are JSON unless an operation says otherwise. Field names are
+camelCase, times are ISO 8601 in UTC, and sizes are in bytes. Where a body is
+optional, or a field is left out of a `PATCH`, what is left out stays as it is.
+
+A request that is refused is answered with a status code and a JSON body:
+
+```json
+{
+ "status": 409,
+ "error": "Conflict",
+ "message": "This key is already in use."
+}
+```
+
+- `400` **Invalid**: the request cannot be carried out as it is. The message says
+ what to change.
+- `403` **Forbidden**: not for this lambda - it is a demo, or its tier does not
+ include what was asked for.
+- `404` **NotFound**: the lambda, version, feature, file or table does not exist.
+- `409` **Conflict**: it collides with the state of the lambda - a key or a domain
+ that is taken, a feature that is behind the newest version, a save based on a
+ state that changed in the meantime. Read again, then retry.
+- `429` **TooMany**: asked for too often lately. Try again later.
+
+The `message` is written for a person or an agent to act on. Pass it on rather
+than replacing it. Code that does not compile is not an error of the request:
+deploying, previewing and merging answer it with `422` and the compiler's
+`diagnostics`, and leave what was online as it was.
+
+A request that fails before the platform's own checks - a body that is not valid
+JSON, a build agent that cannot be reached - is answered with the framework's
+error body, which has `status` and `message` but no `error`.
+
+## Other ways in
+
+The same platform is available to AI agents as an MCP server at `/mcp`. Every
+lambda is also a git repository: clone it from the `gitPath` of the lambda, push
+to `main` to save versions, and push a branch to start a feature.
diff --git a/src/GenHTTP.Lambda/Resources/Export/Dockerfile.txt b/src/GenHTTP.Lambda/Resources/Export/Dockerfile.txt
index 42a00cc4..25a3e89f 100644
--- a/src/GenHTTP.Lambda/Resources/Export/Dockerfile.txt
+++ b/src/GenHTTP.Lambda/Resources/Export/Dockerfile.txt
@@ -8,7 +8,7 @@ RUN dotnet restore
COPY . .
RUN dotnet publish -c Release --no-restore -o /app
-FROM mcr.microsoft.com/dotnet/aspnet:10.0
+FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
diff --git a/src/GenHTTP.Lambda/Services/Building/BuildModel.cs b/src/GenHTTP.Lambda/Services/Building/BuildModel.cs
index be9344a3..25aa384f 100644
--- a/src/GenHTTP.Lambda/Services/Building/BuildModel.cs
+++ b/src/GenHTTP.Lambda/Services/Building/BuildModel.cs
@@ -1,178 +1,176 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Building;
-///
-/// A build the agent has taken on.
-///
-/// What to ask for its progress by
-/// How many builds are waiting ahead of it
-public sealed record BuildStarted(string Id, int Queued);
+[Summary("A build the agent has taken on.")]
+public sealed record BuildStarted(
+ [Summary("What to follow it by: `GET /builds/{id}`")]
+ string Id,
+ [Summary("How many builds are waiting ahead of it")]
+ int Queued
+);
-///
-/// How a build is getting on.
-///
-/// queued, running, done or failed
-///
-/// What it has done so far, as facts the build page says in the visitor's
-/// language - the same steps the control center shows of a change
-///
-/// How it ended, once it has
-/// Its place in the queue, zero once it runs
-/// How long it has been running, counted by the agent so a wrong clock in the browser does not matter
-/// How long it may run, in seconds; nothing when there is no clock on it
-public sealed record BuildProgress(string State, IReadOnlyList Steps, BuildResult? Result, int Waiting, int Seconds = 0, int? Limit = null);
+[Summary("How a build is getting on.")]
+public sealed record BuildProgress(
+ [Summary("`queued`, `running`, `done` or `failed`")]
+ string State,
+ [Summary("What it has done so far, as facts a page can say in the visitor's language - the same steps the editor shows of a change")]
+ IReadOnlyList Steps,
+ [Summary("How it ended, once it has")]
+ BuildResult? Result,
+ [Summary("Its place in the queue; zero once it runs")]
+ int Waiting,
+ [Summary("How long it has been running, in seconds, counted by the agent")]
+ int Seconds = 0,
+ [Summary("How long it may run, in seconds; absent when there is no clock on it")]
+ int? Limit = null
+);
-///
-/// How a build ended.
-///
-/// What the person needs to be told about the editor key
-/// What went wrong, in the words of whatever it went wrong in
-///
-/// The agent would not build what was asked for, because it is not an application
-/// or not one this platform is for; Error is what it said, in the language
-/// of the request
-///
+[Summary("How a build ended.")]
public sealed record BuildResult(
+ [Summary("Whether a lambda was made")]
bool Ok,
+ [Summary("Whether it is online")]
bool? Deployed,
+ [Summary("The public key of the lambda that was made")]
string? PublicKey,
+ [Summary("Its editor key - handed out here, to whoever asked for the build, and nowhere else")]
string? PrivateKey,
+ [Summary("Where it answers")]
string? Url,
+ [Summary("Where its editor is")]
string? EditorUrl,
+ [Summary("What the agent said it made")]
string? Summary,
+ [Summary("What the person needs to be told about keeping the editor key")]
string? Keep,
+ [Summary("What went wrong, or - where it was declined - why, in the language of the request")]
string? Error,
+ [Summary("What went wrong, in the words of whatever it went wrong in")]
string? Detail,
+ [Summary("Whether the agent would not build what was asked for, because it is not an application or not one this platform is for; `error` says why")]
bool? Declined = null
);
-///
-/// One thing the agent did while building or changing a lambda, as the build
-/// page and the control center draw it.
-///
///
/// Facts rather than sentences, so the pages can say them in the visitor's or
/// the owner's language: the kind of step, what it was about, and - once the
/// tool has answered - how it went. The only prose is what the agent itself
/// said between steps, which it was asked to write in the owner's language.
///
-/// Seconds into the run
-///
-/// say, guide, demos, read, logs, create, write, check, deploy, upload, delete,
-/// list, feature (started one), update (changed one's notes or base), merge,
-/// discard (deleted one), data (switched a kind of data on), records (read the
-/// database), secrets (listed the secrets) or other
-///
-/// The kind of data a step of kind data switched on: database, secrets or workspace
-/// What the agent said, for a step of kind say
-/// The tool, for a step of a kind this list does not name
-/// The files written or read
-/// The files a write removed
-/// Whether a write replaced every file rather than some
-/// The workspace path of an upload or a removal
-/// Whether the tool has answered
-/// The version read, saved, merged into, put online, or made a feature's base
-/// Whether a write or a deployment went online - at the preview address, for a feature
-/// The name of the feature the step was about
-/// Whether the step was about a feature rather than the lambda itself
-/// How many errors the compiler found
-/// How many errors the log holds
-/// Why the platform refused the step, in its words
+[Summary("One thing the agent did while building or changing a lambda.")]
+[Remarks("A fact rather than a sentence, so a page can say it in its own language. The only prose is what the agent itself said between steps (`say`).")]
public sealed record AgentStep(
+ [Summary("Seconds into the run")]
int At,
+ [Summary("What it did: `say`, `guide`, `demos`, `read`, `logs`, `create`, `write`, `check`, `deploy`, `upload`, `delete`, `list`, `feature` (started one), `update` (changed a feature's notes or base), `merge`, `discard` (deleted a feature), `data` (switched a kind of data on), `records` (read the database), `secrets` (listed the secrets) or `other`")]
string Kind,
+ [Summary("What the agent said, for a step of kind `say`")]
string? Text = null,
+ [Summary("The tool, for a step of kind `other`")]
string? Tool = null,
+ [Summary("The files written or read")]
IReadOnlyList? Files = null,
+ [Summary("The files a write removed")]
IReadOnlyList? Removed = null,
+ [Summary("Whether a write replaced every file rather than some")]
bool? Whole = null,
+ [Summary("The workspace path of an upload or a removal")]
string? Path = null,
+ [Summary("Whether the tool has answered")]
bool? Done = null,
+ [Summary("The version read, saved, merged into, put online, or made a feature's base")]
int? Version = null,
+ [Summary("Whether a write or a deployment went online - at the preview address, for a feature")]
bool? Online = null,
+ [Summary("How many errors the compiler found")]
int? Errors = null,
+ [Summary("How many errors the log holds")]
int? Problems = null,
+ [Summary("Why the platform refused the step, in its words")]
string? Problem = null,
+ [Summary("The name of the feature the step was about")]
string? Feature = null,
+ [Summary("Whether the step was about a feature rather than the lambda itself")]
bool? Preview = null,
+ [Summary("The kind of data a step of kind `data` switched on: `database`, `secrets` or `workspace`")]
string? Data = null
);
-///
-/// How a change ended, from what the tools answered rather than from what the
-/// model said about them.
-///
-/// Whether it saved anything - into a feature, or as a version
-/// The newest version it saved, merging included
-/// The version it put online, if it put one there
-/// Whether it put a version online
-/// Whether the last version it compiled compiles, where it is known
-/// The version that was online when it started
-/// It saved nothing
-/// Somebody stopped it
-///
-/// What cut it short - timeout, turns (it used up its steps) or unauthorised - or,
-/// with nothing saved and nothing said, nothing; declined when the agent would
-/// not make the change at all
-///
-/// The agent would not make the change; Summary says why
-/// What the agent said at the end, for the owner
-/// What went wrong, in English, for a client without words of its own
-/// What went wrong, in the words of whatever it went wrong in
-/// The feature the change was left in, by its key, when it was not merged
-/// What that feature is called
-/// Whether that feature's preview is online, to be tried
+[Summary("How a change ended, from what the tools answered rather than from what the model said about them.")]
public sealed record ChangeResult(
+ [Summary("Whether it saved anything - into a feature, or as a version")]
bool Ok,
+ [Summary("The newest version it saved, merging included")]
int? Version = null,
+ [Summary("The version it put online, if it put one there")]
int? Online = null,
+ [Summary("Whether it put a version online")]
bool? Deployed = null,
+ [Summary("Whether the last code it compiled compiles, where it is known")]
bool? Compiles = null,
+ [Summary("The version that was online when it started - to put back, if the change is not wanted")]
int? Before = null,
+ [Summary("Whether it saved nothing")]
bool? Unchanged = null,
+ [Summary("Whether somebody stopped it")]
bool? Cancelled = null,
+ [Summary("What cut it short: `timeout`, `turns` (it used up its steps) or `unauthorised`; `declined` where the agent would not make the change at all")]
string? Reason = null,
+ [Summary("What the agent said at the end, for the owner - or, where it declined, why")]
string? Summary = null,
+ [Summary("What went wrong, in English")]
string? Error = null,
+ [Summary("What went wrong, in the words of whatever it went wrong in")]
string? Detail = null,
+ [Summary("The key of the feature the change was left in, where it was not merged")]
string? Feature = null,
+ [Summary("What that feature is called")]
string? FeatureName = null,
+ [Summary("Whether that feature's preview is online, to be tried")]
bool? Preview = null,
+ [Summary("Whether the agent would not make the change; `summary` says why")]
bool? Declined = null
);
-///
-/// A change the agent was asked for, and how it is getting on.
-///
-/// queued, running, done, failed or cancelled
-/// What was asked for
-/// Whether it was asked to merge the change and put it online
-/// Which of the offered models is doing it
-/// The version that was online when it was asked for
-/// Its place in the queue, zero once it runs
-/// How long it has been running
-/// How long it may run, in seconds; null when there is no clock on it
-/// The feature it was asked to go on with, by its key; null when it starts one of its own
+[Summary("A change the agent was asked for, and how it is getting on.")]
public sealed record ChangeProgress(
+ [Summary("What the agent knows it by")]
string Id,
+ [Summary("`queued`, `running`, `done`, `failed` or `cancelled`")]
string State,
+ [Summary("What was asked for")]
string Prompt,
+ [Summary("Whether it was asked to merge the change and put it online")]
bool Deploy,
+ [Summary("Which of the offered models is making it")]
string Model,
+ [Summary("The version that was online when it was asked for")]
int? Before,
+ [Summary("What it has done so far")]
IReadOnlyList Steps,
+ [Summary("How it ended, once it has")]
ChangeResult? Result,
+ [Summary("Its place in the queue; zero once it runs")]
int Waiting,
+ [Summary("How long it has been running, in seconds")]
int Seconds,
+ [Summary("How long it may run, in seconds; absent when there is no clock on it")]
int? Limit,
+ [Summary("The key of the feature it was asked to go on with; absent when it starts one of its own")]
string? Feature = null
);
-///
-/// What the Change section of the control center needs to draw itself.
-///
-/// Whether this installation has an agent at all
-/// How many builds and changes one address may ask for in a day
-/// How many of those this caller has left today
-/// Whether a second model is on offer, behind a password
-/// The change under way, or the last one, while the agent still remembers it
-public sealed record AgentState(bool Available, int PerDay, int Left, bool SecondModel, ChangeProgress? Job);
+[Summary("Whether a change can be asked for, and the change under way or the last one.")]
+public sealed record AgentState(
+ [Summary("Whether changes can be asked for: there is an agent, and the operator has not switched the box off")]
+ bool Available,
+ [Summary("How many builds and changes one address may ask for in a day")]
+ int PerDay,
+ [Summary("How many of those this caller has left today")]
+ int Left,
+ [Summary("Whether a second model is on offer, behind a password")]
+ bool SecondModel,
+ [Summary("The change under way, or the last one, while the agent still remembers it")]
+ ChangeProgress? Job
+);
diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs
index dabb8a66..f636f68f 100644
--- a/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs
+++ b/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs
@@ -1,11 +1,25 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Deployment.Model;
///
/// A single message produced while compiling the code of a lambda. Line and
/// column refer to the code as the user wrote it (one based).
///
-/// Which file it is in, absent when it is about none of them
-public sealed record CompilationDiagnostic(string Severity, string Id, string Message, int Line, int Column, string? File = null)
+[Summary("One thing the compiler, or the platform's checks of the code, said about it.")]
+public sealed record CompilationDiagnostic(
+ [Summary("`Error`, which keeps the code from going online, or `Warning`")]
+ string Severity,
+ [Summary("What kind of message it is: the C# compiler's (`CS1002`), `LAMBDA0001` for something lambdas may not do, `LAMBDA0002` for a handler the platform cannot serve, or `LAMBDA` for anything else the platform refused")]
+ string Id,
+ [Summary("What is wrong, and often what to do about it")]
+ string Message,
+ [Summary("The line in the file, counting from 1; 0 when it is about no line")]
+ int Line,
+ [Summary("The column in the line, counting from 1; 0 when it is about no line")]
+ int Column,
+ [Summary("The name of the file it is in, as the lambda names it; absent when it is about none of them")]
+ string? File = null)
{
public static CompilationDiagnostic Error(string message) => new("Error", "LAMBDA", message, 0, 0);
diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs
index f7c307f2..3cc2167c 100644
--- a/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs
+++ b/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs
@@ -1,7 +1,18 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Deployment.Model;
///
/// A single suggestion for the code editor. is set for
/// snippets, where the text to insert differs from the label.
///
-public sealed record CompletionItem(string Label, string Kind, string Detail, string? Insert = null);
+[Summary("A name or a snippet the editor may suggest, whatever the code around it.")]
+public sealed record CompletionItem(
+ [Summary("What is suggested: a type a lambda may use, an attribute, or the name of a snippet")]
+ string Label,
+ [Summary("What it is: `class`, `struct`, `interface`, `enum`, `attribute` or `snippet`")]
+ string Kind,
+ [Summary("Where it comes from - the namespace of a type - or what a snippet does")]
+ string Detail,
+ [Summary("For a snippet, the code to insert; absent where it is the label itself")]
+ string? Insert = null);
diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs
index 2a0f4edc..4e0899fe 100644
--- a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs
+++ b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs
@@ -1,14 +1,17 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Meta;
namespace GenHTTP.Lambda.Services.Deployment.Model;
-///
-/// A text replacement within one file.
-///
-/// The name of the file to change
-/// The text to replace, which has to occur exactly once
-/// What to put in its place
-public sealed record FileEdit(string File, string Find, string Replace);
+[Summary("A replacement of text within one file.")]
+public sealed record FileEdit(
+ [Summary("The name of the file to change, which has to be text")]
+ string File,
+ [Summary("The text to replace, exactly as it is in the file. It has to occur exactly once - include more of what surrounds it where it occurs more often.")]
+ string Find,
+ [Summary("What to put in its place; empty removes it")]
+ string Replace);
///
/// Applies a partial change to the files of a version.
diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs
index d6d76998..aaab283e 100644
--- a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs
+++ b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs
@@ -2,6 +2,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Deployment.Model;
///
@@ -9,10 +11,15 @@ namespace GenHTTP.Lambda.Services.Deployment.Model;
/// kept with it - or one of its resources, which it reads and serves while it
/// runs.
///
-/// What it is called, which is also what diagnostics name
-/// Its contents, base64 when says so
-/// "base64" for a file that is not text, absent otherwise
-public sealed record LambdaFile(string Name, [property: JsonConverter(typeof(LongStringConverter))] string Code, string? Encoding = null)
+[Summary("One file of a lambda.")]
+[Remarks("`lambda.cs` and the other `.cs` files are compiled, files below `resources/` are served and read while the lambda runs, and every other file is kept with the version.")]
+public sealed record LambdaFile(
+ [Summary("Its path, with forward slashes: `lambda.cs`, `Store.cs`, `resources/web/index.html`, `docs/product.md`. Case sensitive, and also what the compiler's messages name.")]
+ string Name,
+ [Summary("Its content: the text itself, or base64 where `encoding` says so")]
+ [property: JsonConverter(typeof(LongStringConverter))] string Code,
+ [Summary("`base64` for a file that is not text - a picture, a font; left out for text. C# files are always text.")]
+ string? Encoding = null)
{
/// Whether this is C# the lambda is compiled from.
diff --git a/src/GenHTTP.Lambda/Services/Deployment/ProjectPacker.cs b/src/GenHTTP.Lambda/Services/Deployment/ProjectPacker.cs
index 5b8b5d89..1194da77 100644
--- a/src/GenHTTP.Lambda/Services/Deployment/ProjectPacker.cs
+++ b/src/GenHTTP.Lambda/Services/Deployment/ProjectPacker.cs
@@ -238,9 +238,8 @@ public static void Pack(ExportedLambda lambda, IReadOnlyList files,
Write(archive, $"{name}/LICENSE", SourceLicenses.Text(license.License, lambda.Saved.Year, license.Holder));
}
- // the aspnet image rather than runtime, although nothing here uses
- // ASP.NET Core: GenHTTP.Full depends on GenHTTP.Testing, which
- // brings the Kestrel engine and with it Microsoft.AspNetCore.App
+ // the plain runtime image: since 11.1, GenHTTP.Full no longer brings
+ // GenHTTP.Testing, and with it the Kestrel engine and ASP.NET Core
Write(archive, $"{name}/Dockerfile", Resource("Dockerfile").Replace("{assembly}", name));
// what the app keeps is kept out of the repository - data is
diff --git a/src/GenHTTP.Lambda/Services/Meta/EventReader.cs b/src/GenHTTP.Lambda/Services/Meta/EventReader.cs
index 27ea7950..e0a0adc4 100644
--- a/src/GenHTTP.Lambda/Services/Meta/EventReader.cs
+++ b/src/GenHTTP.Lambda/Services/Meta/EventReader.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Data;
using GenHTTP.Lambda.Data.Entities;
@@ -81,11 +83,18 @@ public EventHistory History(int days)
}
/// Counts per day, one series per kind of event.
-/// The days covered, as yyyy-MM-dd, oldest first
-/// One entry per kind, aligned to Days
-public sealed record EventHistory(IReadOnlyList Days, IReadOnlyList Series);
-
-/// created, saved, deployed, undeployed or deleted
-/// How many happened on each day of the window
-/// How many have ever happened, beyond the window
-public sealed record EventSeries(string Kind, IReadOnlyList Counts, int Total);
+[Summary("What happened on the platform per day, one series per kind of event.")]
+public sealed record EventHistory(
+ [Summary("The days covered, as `yyyy-MM-dd` in UTC, oldest first")]
+ IReadOnlyList Days,
+ [Summary("One series per kind of event, its counts in the order of `days`")]
+ IReadOnlyList Series);
+
+[Summary("How often one kind of event happened, day by day.")]
+public sealed record EventSeries(
+ [Summary("`created`, `saved`, `deployed`, `undeployed` or `deleted` - of lambdas")]
+ string Kind,
+ [Summary("How many happened on each day of the window")]
+ IReadOnlyList Counts,
+ [Summary("How many have ever happened, beyond the window too")]
+ int Total);
diff --git a/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs b/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs
index 6bbfa633..347381a1 100644
--- a/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs
+++ b/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs
@@ -1,6 +1,8 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
+using GenHTTP.Api.Content.Services;
+
using GenHTTP.Lambda.Services.Databases;
using GenHTTP.Lambda.Services.Deployment.Model;
using GenHTTP.Lambda.Services.Storage;
@@ -22,13 +24,20 @@ public sealed record VersionFacts(int CodeFiles, long CodeBytes, int ResourceFil
/// What a version says about itself: the pages of its code kept in
/// docs/ and tests/.
///
-/// The first paragraph of its product page, as plain text - what the app is, in a sentence or two
-/// Whether it has a product page: what the app is, for whom, and why
-/// Whether it says which technical decisions were made, and why
-/// Whether it says how it is tested
-/// How many files its documentation and tests come to
-/// What those weigh, which counts towards what a version may come to like the rest of its code
-public sealed record DocumentationSummary(string? About, bool Product, bool Decisions, bool Tests, int Files, long Bytes);
+[Summary("What a version says about itself, in `docs/` and `tests/`.")]
+public sealed record DocumentationSummary(
+ [Summary("The first paragraph of `docs/product.md`, as plain text - what the app is, in a sentence or two")]
+ string? About,
+ [Summary("Whether it has `docs/product.md`: what the app is, for whom, and why")]
+ bool Product,
+ [Summary("Whether it has `docs/decisions.md`: which technical decisions were made, and why")]
+ bool Decisions,
+ [Summary("Whether it has `tests/README.md`: how it is tested")]
+ bool Tests,
+ [Summary("How many files its documentation and tests come to")]
+ int Files,
+ [Summary("What those weigh, which counts towards what a version may come to like the rest of its code")]
+ long Bytes);
///
/// The facts of the versions whose overview was asked for lately, read off
diff --git a/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs b/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs
index e5528d9c..08676fa4 100644
--- a/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs
+++ b/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Telemetry;
///
@@ -8,19 +10,24 @@ namespace GenHTTP.Lambda.Services.Telemetry;
/// of lambdas is not bounded by anything this process controls, and a ring
/// buffer each would grow with them.
///
-///
-/// What the responses carried, counting only the ones that declared a length.
-/// A response streamed without one adds nothing here, so this is a floor rather
-/// than a total.
-///
+[Summary("What one lambda has been doing since the server came up, in totals.")]
public sealed record LambdaActivity(
+ [Summary("The lambda, by its public key")]
string PublicKey,
+ [Summary("Requests it answered")]
long Requests,
+ [Summary("Of those, answered with a server error")]
long Failed,
+ [Summary("Connections upgraded to a websocket")]
long Upgrades,
+ [Summary("The mean time to answer, in milliseconds")]
double AverageMillis,
+ [Summary("The longest time to answer, in milliseconds")]
double SlowestMillis,
+ [Summary("What the responses carried, counting only those that said how much - a floor rather than a total")]
long BytesOut,
+ [Summary("When the first request arrived")]
DateTime? FirstSeen,
+ [Summary("When the last request arrived")]
DateTime? LastSeen
);
diff --git a/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs b/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs
index adc509a4..9e04c6f6 100644
--- a/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs
+++ b/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs
@@ -1,49 +1,76 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Telemetry;
///
/// What one lambda has been doing, in enough detail for its owner to read a
/// trend rather than a total.
///
-/// Everything since the server came up
-/// The last hour, a minute at a time, oldest first
-/// The last day, fifteen minutes at a time, oldest first
-/// How the requests were answered, by class
-/// What was asked for, busiest first
-/// Where it was reached - its subdomain of the hosting domain, or its own domain - busiest first
-/// When the counting started, which is when the server came up
+[Summary("What one lambda has been doing since the server came up.")]
+[Remarks("Counted in memory, so a restart of the server starts every figure again from nothing (`since`).")]
public sealed record LambdaTraffic(
+ [Summary("Everything since the server came up; absent while nobody has called it")]
LambdaActivity? Totals,
+ [Summary("The last hour, a minute at a time, oldest first")]
IReadOnlyList Minutes,
+ [Summary("The last day, a quarter of an hour at a time, oldest first")]
IReadOnlyList Quarters,
+ [Summary("How the requests were answered, by the class of their status")]
StatusClasses Statuses,
+ [Summary("What was asked for, the busiest paths first")]
IReadOnlyList Paths,
+ [Summary("Where it was reached - its subdomain of the hosting domain, or its own domain - the busiest first")]
IReadOnlyList Entrances,
+ [Summary("When the counting started, which is when the server came up")]
DateTime Since
);
-///
-/// One interval of traffic.
-///
-/// When the interval started
-/// Answered with a server error
-/// Answered with a client error, which includes not found
-/// The mean time to answer, over the requests of the interval
-public sealed record TrafficPoint(DateTime At, int Requests, int Failed, int Rejected, int Upgrades, double AverageMillis, long Bytes);
+[Summary("The traffic of one interval.")]
+public sealed record TrafficPoint(
+ [Summary("When the interval started")]
+ DateTime At,
+ [Summary("Requests answered in it")]
+ int Requests,
+ [Summary("Of those, answered with a server error (5xx)")]
+ int Failed,
+ [Summary("Of those, answered with a client error (4xx), not found included")]
+ int Rejected,
+ [Summary("Connections upgraded to a websocket")]
+ int Upgrades,
+ [Summary("The mean time to answer, over its requests, in milliseconds")]
+ double AverageMillis,
+ [Summary("What the responses carried, where they said how much")]
+ long Bytes
+);
-///
-/// Requests by the class of status they were answered with.
-///
-public sealed record StatusClasses(long Success, long Redirect, long ClientError, long ServerError);
+[Summary("Requests by the class of status they were answered with.")]
+public sealed record StatusClasses(
+ [Summary("2xx")]
+ long Success,
+ [Summary("3xx")]
+ long Redirect,
+ [Summary("4xx, not found included")]
+ long ClientError,
+ [Summary("5xx")]
+ long ServerError
+);
-///
-/// One path of a lambda and what it was asked.
-///
-/// Relative to the lambda, so the same wherever it is hosted
-public sealed record PathTraffic(string Path, long Requests, long Failed, double AverageMillis);
+[Summary("One path of a lambda, and how it was answered.")]
+public sealed record PathTraffic(
+ [Summary("The path, relative to the lambda - the same wherever it is reached")]
+ string Path,
+ [Summary("How often it was asked for")]
+ long Requests,
+ [Summary("Of those, answered with a server error")]
+ long Failed,
+ [Summary("The mean time to answer it, in milliseconds")]
+ double AverageMillis
+);
-///
-/// How often a lambda was reached through one of the ways it can be reached.
-///
-/// The host it was reached at - its subdomain of the hosting domain, or its own domain
-/// Requests and upgrades alike - every time somebody came in this way
-public sealed record EntranceTraffic(string? Domain, long Requests);
+[Summary("How often a lambda was reached through one of its addresses.")]
+public sealed record EntranceTraffic(
+ [Summary("The host it was reached at - its subdomain of the hosting domain, or its own domain")]
+ string? Domain,
+ [Summary("Requests and websocket upgrades alike - every time somebody came in this way")]
+ long Requests
+);
diff --git a/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs b/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs
index 2be84b81..0368481d 100644
--- a/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs
+++ b/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs
@@ -1,3 +1,5 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Telemetry;
///
@@ -10,50 +12,89 @@ namespace GenHTTP.Lambda.Services.Telemetry;
/// the committed and fragmented bytes tell apart a heap that is genuinely
/// holding objects from one that has simply not been given back to the system.
///
+[Summary("One reading of the server process.")]
+[Remarks("The breakdown of the resident memory and the descriptors are read from `/proc` on Linux, and are zero elsewhere.")]
public sealed record TelemetrySample(
+ [Summary("When it was taken")]
DateTime Taken,
// memory
+
+ [Summary("What the managed heap holds")]
long ManagedBytes,
+ [Summary("What the managed heap has committed")]
long HeapCommittedBytes,
+ [Summary("Of that, what lies free between objects")]
long HeapFragmentedBytes,
+ [Summary("The working set of the process")]
long WorkingSetBytes,
+ [Summary("The private memory of the process")]
long PrivateBytes,
// where the resident set actually went, which the runtime cannot say
+
+ [Summary("Everything the process has in physical memory")]
long ResidentBytes,
+ [Summary("Of that, anonymous memory: heaps and stacks")]
long AnonymousBytes,
+ [Summary("Of that, code the JIT compiled")]
long JitBytes,
+ [Summary("Of that, assemblies mapped from disk")]
long AssemblyBytes,
+ [Summary("Of that, other files mapped from disk")]
long OtherFileBytes,
+ [Summary("What was swapped out")]
long SwapBytes,
// the collector
+
+ [Summary("Generation 0 collections since the start")]
int Gen0Collections,
+ [Summary("Generation 1 collections since the start")]
int Gen1Collections,
+ [Summary("Generation 2 collections since the start")]
int Gen2Collections,
+ [Summary("Everything allocated since the start")]
long AllocatedBytes,
+ [Summary("The share of time spent paused for collections, in percent")]
double PausePercentage,
// the process
+
+ [Summary("The share of every core the process used since the previous reading, in percent")]
double CpuPercentage,
+ [Summary("Threads of the process")]
int Threads,
// what the server did since the previous sample
+
+ [Summary("Requests answered since the previous reading")]
long Requests,
+ [Summary("Of those, answered with a server error")]
long Failed,
+ [Summary("Websocket upgrades since the previous reading")]
long Upgrades,
+ [Summary("Requests being answered as it was taken")]
int InFlight,
+ [Summary("Open connections as it was taken")]
int OpenSockets,
+ [Summary("The mean time to answer since the previous reading, in milliseconds")]
double AverageMillis,
// connections, which are not the same thing as requests: a visitor who
// reloads a page opens a new one, while a browser holding a tab open
// makes many requests down a single one
+
+ [Summary("Connections established as it was taken")]
int OpenConnections,
+ [Summary("Connections accepted since the start")]
long AcceptedConnections,
+ [Summary("Connections accepted since the previous reading")]
long Connections,
+ [Summary("File descriptors the process holds")]
int FileDescriptors,
+ [Summary("Of those, sockets")]
int SocketDescriptors,
+ [Summary("Of those, io_uring rings")]
int RingDescriptors
);
diff --git a/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs b/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs
index 7fc5d848..b549562c 100644
--- a/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs
+++ b/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs
@@ -1,9 +1,18 @@
+using GenHTTP.Api.Content.Services;
+
namespace GenHTTP.Lambda.Services.Workspace;
///
/// One file in the workspace of a lambda.
///
-public sealed record WorkspaceEntry(string Path, long Size, DateTime Modified);
+[Summary("One file of a workspace.")]
+public sealed record WorkspaceEntry(
+ [Summary("Its path within the workspace, with forward slashes: `logs/today.txt`")]
+ string Path,
+ [Summary("How many bytes it holds")]
+ long Size,
+ [Summary("When it was last written")]
+ DateTime Modified);
///
/// The workspace as the editor shows it: what is in it, and how much of it is
@@ -15,14 +24,17 @@ public sealed record WorkspaceEntry(string Path, long Size, DateTime Modified);
/// can be made before there is anything to put in it - and inferring them
/// would make it vanish the moment it was created.
///
-/// The room the files and folders take, counted in blocks as the quota is
-/// The room the tier of the lambda gives it
-/// Whether the owner left the workspace switched on; off, it holds nothing and takes nothing
+[Summary("What a workspace holds, and how much room it has left.")]
public sealed record WorkspaceListing(
+ [Summary("Every file, in every folder")]
IReadOnlyList Files,
+ [Summary("Every folder, by its path - listed apart, since a folder may be empty")]
IReadOnlyList Folders,
+ [Summary("The room the files and folders take, counted in blocks of 4 KB as the allowance is")]
long UsedBytes,
+ [Summary("The room the workspace may take: the data allowance of the lambda's tier, less what its database takes")]
long QuotaBytes,
+ [Summary("Whether the workspace is switched on; while it is off, it holds nothing and nothing can be written")]
bool Enabled = true
);
diff --git a/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs b/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs
new file mode 100644
index 00000000..97127335
--- /dev/null
+++ b/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs
@@ -0,0 +1,348 @@
+using System.Net;
+using System.Text.Json;
+
+using GenHTTP.Lambda.Tests.Infrastructure;
+
+using GenHTTP.Testing;
+
+namespace GenHTTP.Lambda.Tests.Api;
+
+///
+/// The OpenAPI document as whoever calls the API reads it: every operation,
+/// parameter and field says what it is, and every answer says what it means.
+///
+///
+/// What is checked is the document as it is served, so these fail for an
+/// endpoint or a field that was added without saying anything about itself -
+/// which is how a description goes missing.
+///
+[TestClass]
+public sealed class ApiDocumentationTests
+{
+
+ #region Operations
+
+ [TestMethod]
+ public async Task EveryOperationSaysWhatItDoes()
+ {
+ using var document = await ReadAsync();
+
+ var missing = Operations(document).Where(o => string.IsNullOrWhiteSpace(Text(o.Operation, "summary")))
+ .Select(o => o.Name)
+ .ToList();
+
+ Assert.IsEmpty(missing, $"Without a summary: {string.Join(", ", missing)}");
+ }
+
+ [TestMethod]
+ public async Task EveryParameterIsDescribed()
+ {
+ using var document = await ReadAsync();
+
+ var missing = new List();
+
+ foreach (var (name, operation) in Operations(document))
+ {
+ if (!operation.TryGetProperty("parameters", out var parameters))
+ {
+ continue;
+ }
+
+ missing.AddRange(parameters.EnumerateArray()
+ .Where(p => string.IsNullOrWhiteSpace(Text(p, "description")))
+ .Select(p => $"{name} {p.GetProperty("name").GetString()}"));
+ }
+
+ Assert.IsEmpty(missing, $"Without a description: {string.Join(", ", missing)}");
+ }
+
+ [TestMethod]
+ public async Task EveryAnswerSaysWhatItMeans()
+ {
+ using var document = await ReadAsync();
+
+ var missing = new List();
+
+ foreach (var (name, operation) in Operations(document))
+ {
+ foreach (var response in operation.GetProperty("responses").EnumerateObject())
+ {
+ if (string.IsNullOrWhiteSpace(Text(response.Value, "description")))
+ {
+ missing.Add($"{name} {response.Name}");
+ }
+ }
+ }
+
+ Assert.IsEmpty(missing, $"Without a description: {string.Join(", ", missing)}");
+ }
+
+ [TestMethod]
+ public async Task EveryCallAboutALambdaSaysHowAnUnknownKeyIsAnswered()
+ {
+ using var document = await ReadAsync();
+
+ var missing = Operations(document).Where(o => o.Name.Contains("/lambdas/{privateKey}", StringComparison.Ordinal))
+ .Where(o => !Refusal(o.Operation, "404"))
+ .Select(o => o.Name)
+ .ToList();
+
+ Assert.IsEmpty(missing, $"Without a 404 carrying an error: {string.Join(", ", missing)}");
+ }
+
+ [TestMethod]
+ public async Task WhatOnlyTheOperatorMayCallAsksForTheToken()
+ {
+ using var document = await ReadAsync();
+
+ var operations = Operations(document).Where(o => o.Name.Contains(" /admin", StringComparison.Ordinal)
+ || o.Name.Contains(" /telemetry", StringComparison.Ordinal)
+ || o.Name.Contains(" /logs", StringComparison.Ordinal))
+ .ToList();
+
+ Assert.IsNotEmpty(operations, "an installation with a token describes its panel");
+
+ foreach (var (name, operation) in operations)
+ {
+ var token = operation.GetProperty("parameters").EnumerateArray()
+ .FirstOrDefault(p => p.GetProperty("name").GetString() == "X-Admin-Token");
+
+ Assert.AreEqual("header", token.GetProperty("in").GetString(), $"{name} asks for the token in a header");
+ Assert.IsTrue(token.GetProperty("required").GetBoolean(), $"{name} requires the token");
+
+ var responses = operation.GetProperty("responses");
+
+ Assert.IsTrue(responses.TryGetProperty("401", out _), $"{name} says how a missing token is answered");
+ Assert.IsTrue(responses.TryGetProperty("403", out _), $"{name} says how a wrong token is answered");
+ }
+ }
+
+ #endregion
+
+ #region What is answered
+
+ [TestMethod]
+ public async Task WhatIsCreatedIsAnsweredWithCreated()
+ {
+ using var document = await ReadAsync();
+
+ foreach (var name in new[] { "post /lambdas", "post /lambdas/{privateKey}/versions", "post /lambdas/{privateKey}/versions/zip",
+ "post /lambdas/{privateKey}/versions/changes", "post /lambdas/{privateKey}/features" })
+ {
+ var responses = Operation(document, name).GetProperty("responses");
+
+ Assert.IsTrue(responses.TryGetProperty("201", out var created), $"{name} answers with 201");
+ Assert.IsTrue(created.TryGetProperty("content", out _), $"{name} says what it answers 201 with");
+
+ Assert.IsFalse(responses.TryGetProperty("200", out _), $"{name} never answers with 200");
+ }
+ }
+
+ [TestMethod]
+ public async Task WhatTheAgentWillDoIsAnsweredWithAccepted()
+ {
+ using var document = await ReadAsync();
+
+ foreach (var name in new[] { "post /builds/", "post /lambdas/{privateKey}/agent/start" })
+ {
+ var responses = Operation(document, name).GetProperty("responses");
+
+ Assert.IsTrue(responses.TryGetProperty("202", out _), $"{name} answers with 202");
+ Assert.IsFalse(responses.TryGetProperty("200", out _), $"{name} never answers with 200");
+ }
+ }
+
+ [TestMethod]
+ public async Task CodeThatDoesNotCompileIsAnsweredWithWhatTheCompilerSaid()
+ {
+ using var document = await ReadAsync();
+
+ foreach (var name in new[] { "post /lambdas/{privateKey}/deployment/start", "post /lambdas/{privateKey}/features/{feature}/preview/start",
+ "post /lambdas/{privateKey}/features/{feature}/merge" })
+ {
+ var responses = Operation(document, name).GetProperty("responses");
+
+ Assert.IsTrue(responses.TryGetProperty("422", out var refused), $"{name} answers code that does not compile with 422");
+ Assert.IsTrue(refused.TryGetProperty("content", out _), $"{name} says what the compiler said");
+ }
+ }
+
+ [TestMethod]
+ public async Task ArchivesAreDescribedAsArchives()
+ {
+ using var document = await ReadAsync();
+
+ foreach (var name in new[] { "get /lambdas/{privateKey}/export", "get /lambdas/{privateKey}/versions/{version}/zip",
+ "get /lambdas/{privateKey}/features/{feature}/zip", "get /sources/{publicKey}/versions/{version}/zip" })
+ {
+ var ok = Operation(document, name).GetProperty("responses").GetProperty("200");
+
+ Assert.IsTrue(ok.GetProperty("content").TryGetProperty("application/zip", out _), $"{name} answers with a zip");
+ }
+
+ foreach (var name in new[] { "post /lambdas/{privateKey}/versions/zip", "put /lambdas/{privateKey}/features/{feature}/zip" })
+ {
+ var body = Operation(document, name).GetProperty("requestBody");
+
+ Assert.IsTrue(body.GetProperty("content").TryGetProperty("application/zip", out _), $"{name} takes a zip");
+ }
+ }
+
+ [TestMethod]
+ public async Task FilesTakenAsTheyAreAreDescribedAsBytes()
+ {
+ using var document = await ReadAsync();
+
+ foreach (var name in new[] { "put /lambdas/{privateKey}/files/{path}/content", "put /lambdas/{privateKey}/features/{feature}/workspace/{path}/content" })
+ {
+ var body = Operation(document, name).GetProperty("requestBody");
+
+ Assert.IsTrue(body.GetProperty("content").TryGetProperty("application/octet-stream", out _), $"{name} takes the bytes of the file");
+ }
+ }
+
+ #endregion
+
+ #region Models
+
+ [TestMethod]
+ public async Task EveryModelAndFieldIsDescribed()
+ {
+ using var document = await ReadAsync();
+
+ var missing = new List();
+
+ foreach (var schema in document.RootElement.GetProperty("components").GetProperty("schemas").EnumerateObject())
+ {
+ if (string.IsNullOrWhiteSpace(Text(schema.Value, "description")))
+ {
+ missing.Add(schema.Name);
+ }
+
+ if (!schema.Value.TryGetProperty("properties", out var properties))
+ {
+ continue;
+ }
+
+ missing.AddRange(properties.EnumerateObject()
+ .Where(p => string.IsNullOrWhiteSpace(Text(p.Value, "description")))
+ .Select(p => $"{schema.Name}.{p.Name}")
+ .Where(p => !NeverSent.Contains(p)));
+ }
+
+ Assert.IsEmpty(missing, $"Without a description: {string.Join(", ", missing)}");
+ }
+
+ #endregion
+
+ #region Reading order
+
+ [TestMethod]
+ public async Task TheDocumentOpensWithAnIntroduction()
+ {
+ using var document = await ReadAsync();
+
+ var info = document.RootElement.GetProperty("info");
+
+ Assert.AreEqual("GenHTTP Lambda API", info.GetProperty("title").GetString());
+ Assert.AreEqual("v1", info.GetProperty("version").GetString());
+
+ var introduction = info.GetProperty("description").GetString()!;
+
+ foreach (var section in new[] { "## Getting started", "## Who may do what", "## Files", "## Limits", "## Answers and errors" })
+ {
+ Assert.Contains(section, introduction);
+ }
+ }
+
+ [TestMethod]
+ public async Task EveryTagIsDescribedAndInAGroup()
+ {
+ using var document = await ReadAsync();
+
+ var grouped = document.RootElement.GetProperty("x-tagGroups").EnumerateArray()
+ .SelectMany(g => g.GetProperty("tags").EnumerateArray().Select(t => t.GetString()))
+ .ToHashSet();
+
+ var used = Operations(document).SelectMany(o => o.Operation.GetProperty("tags").EnumerateArray().Select(t => t.GetString()!))
+ .ToHashSet();
+
+ var ungrouped = used.Where(t => !grouped.Contains(t)).ToList();
+
+ Assert.IsEmpty(ungrouped, $"In no group: {string.Join(", ", ungrouped)}");
+
+ var tags = document.RootElement.GetProperty("tags").EnumerateArray().ToList();
+
+ var undescribed = tags.Where(t => string.IsNullOrWhiteSpace(Text(t, "description"))).Select(t => t.GetProperty("name").GetString()).ToList();
+
+ Assert.IsEmpty(undescribed, $"Without a description: {string.Join(", ", undescribed)}");
+
+ var unused = grouped.Where(t => !used.Contains(t!)).ToList();
+
+ Assert.IsEmpty(unused, $"Grouped but never used: {string.Join(", ", unused)}");
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Properties the schemas list although they are never sent.
+ ///
+ ///
+ /// GenHTTP 11.1 generates the schemas with Newtonsoft's view of a type,
+ /// while the API serializes with System.Text.Json - so a property marked
+ /// [JsonIgnore] for the one is listed by the other. Reported
+ /// upstream; describing them would document fields nobody receives.
+ ///
+ private static readonly HashSet NeverSent = ["LambdaFile.IsCompiled", "LambdaFile.IsCode", "LambdaFile.IsResource", "LambdaFile.Bytes"];
+
+ ///
+ /// The document of an installation with a panel, which describes the
+ /// operator's endpoints as well.
+ ///
+ private static async Task ReadAsync()
+ {
+ await using var fixture = await LambdaFixture.CreateAsync(LambdaFixture.WithPanel);
+
+ using var response = await fixture.GetAsync("/api/v1/openapi.json");
+
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+
+ return JsonDocument.Parse(await response.GetContentAsync());
+ }
+
+ ///
+ /// Every operation, named by its method and its path: get /lambdas/{privateKey}.
+ ///
+ private static IEnumerable<(string Name, JsonElement Operation)> Operations(JsonDocument document)
+ {
+ foreach (var path in document.RootElement.GetProperty("paths").EnumerateObject())
+ {
+ foreach (var operation in path.Value.EnumerateObject())
+ {
+ yield return ($"{operation.Name} {path.Name}", operation.Value);
+ }
+ }
+ }
+
+ private static JsonElement Operation(JsonDocument document, string name)
+ => Operations(document).FirstOrDefault(o => o.Name == name).Operation is { ValueKind: JsonValueKind.Object } found
+ ? found
+ : throw new AssertFailedException($"There is no operation '{name}'.");
+
+ ///
+ /// Whether the operation says it answers with this status and the body of an error.
+ ///
+ private static bool Refusal(JsonElement operation, string status)
+ => operation.GetProperty("responses").TryGetProperty(status, out var response)
+ && response.TryGetProperty("content", out var content)
+ && content.TryGetProperty("application/json", out var json)
+ && json.GetProperty("schema").GetRawText().Contains("ErrorResponse", StringComparison.Ordinal);
+
+ private static string? Text(JsonElement element, string property)
+ => element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ #endregion
+
+}
diff --git a/tests/GenHTTP.Lambda.Tests/GenHTTP.Lambda.Tests.csproj b/tests/GenHTTP.Lambda.Tests/GenHTTP.Lambda.Tests.csproj
index 9272f195..27ee0580 100644
--- a/tests/GenHTTP.Lambda.Tests/GenHTTP.Lambda.Tests.csproj
+++ b/tests/GenHTTP.Lambda.Tests/GenHTTP.Lambda.Tests.csproj
@@ -14,6 +14,8 @@
+
+
diff --git a/tests/GenHTTP.Lambda.Tests/Lambdas/ProjectPackerTests.cs b/tests/GenHTTP.Lambda.Tests/Lambdas/ProjectPackerTests.cs
index 3c841494..626b3129 100644
--- a/tests/GenHTTP.Lambda.Tests/Lambdas/ProjectPackerTests.cs
+++ b/tests/GenHTTP.Lambda.Tests/Lambdas/ProjectPackerTests.cs
@@ -101,9 +101,9 @@ public void TheContainerRunsWhatThePackageNeeds()
{
var docker = Read(ProjectPacker.Pack(Lambda, Files), "my-lambda/Dockerfile");
- // GenHTTP.Full carries the Kestrel engine, which needs the ASP.NET Core
- // framework to start at all - the plain runtime image exits at once
- Assert.Contains("FROM mcr.microsoft.com/dotnet/aspnet:10.0", docker);
+ // GenHTTP.Full needs nothing of ASP.NET Core since 11.1, so the plain
+ // runtime image is enough - the aspnet one would only be larger
+ Assert.Contains("FROM mcr.microsoft.com/dotnet/runtime:10.0", docker);
Assert.Contains("""ENTRYPOINT ["dotnet", "my-lambda.dll"]""", docker);
}