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); }