diff --git a/docs/runnable-binding-delivery.md b/docs/runnable-binding-delivery.md index f748df65..4b7edccf 100644 --- a/docs/runnable-binding-delivery.md +++ b/docs/runnable-binding-delivery.md @@ -32,7 +32,7 @@ The value is a `codefly.runnable.v0.PreparedBinding`, schema | Field | What it is | | --- | --- | | `operation` | the owner coordinates (module, service, endpoint) and the operation **as the owner spells it**: `/pkg.Service/Method` or `POST /path`. This is the audit identity, and what an effect receipt is keyed by | -| `call` | where one call is sent: the `address` the environment resolves — an HTTP base URL, since both routes are HTTP — and a typed route: `connect` (a procedure POSTed as JSON on the owner's Connect endpoint) or `rest` (the owner's own verb and path with the plain JSON body) | +| `call` | where one call is sent: the `address` the environment resolves — an HTTP base URL, since both routes are HTTP — and a typed route: `connect` (a procedure POSTed as JSON on the owner's Connect endpoint) or `rest` (the owner's own verb and path: `POST` or `PUT` with the plain JSON body, or `GET` — a read only — with the input object as its query, each member one parameter of that name) | | `contract` | the bounded input and output schema, carried whole | | `contract_digest` | `sha256:` over that contract's canonical form (`runnable.ContractDigest`) | | `policy` | the `codefly.runnable.v0.Operation` the owner declared — the attempt budget, the authority (audience, invoke and lookup scopes), the completion mode and, when the owner exposed the operation as a tool, that exposure — with every required scope slot already resolved to the exact ids the composition selected (`runnable.OperationSpec.ResolveScopeSlots`). A writer produces it with `runnable.OperationSpec.Policy` from the resolved spec rather than copying fields, so every field the schema declares travels; a policy still carrying a slot is refused with `runnable.ErrUnresolvedScopeSlots` | @@ -101,6 +101,9 @@ had to act on. `runnable.VerifyPrepared` then holds it to four things: are checked together because they had one cause: a form that read one string as both the audit identity and the dial target refused every REST operation, since `POST /path` is not a method path; +- **a `GET` is a read**: its policy recomputes, its tool (if stated) is + read-only and its invoke scopes require only `read`, the rule the derivation + applies, held again here rather than trusted; - **the policy is inside the bounds an installation enforces**, by reading it back as the `OperationSpec` core already validates. The retry vocabulary follows from the route — `google.rpc.Code` names for `connect`, HTTP statuses diff --git a/docs/runnable.md b/docs/runnable.md index 640a78d5..4abaa30d 100644 --- a/docs/runnable.md +++ b/docs/runnable.md @@ -424,11 +424,12 @@ There are two ways an owner describes an operation, and one derivation: | | gRPC | REST | | --- | --- | --- | | Marking | the `codefly.runnable.v0.operation` method option (`proto/codefly/runnable/v0/options.proto`, a `google.protobuf.MethodOptions` extension) | the `x-codefly-operation` vendor extension on the operation object, the proto3 JSON form of that same `Operation` message | -| Source of the contract | the method's request and response descriptors | the operation's `application/json` request body and its one `2xx` response | -| Builder | `runnable.PackageFromMethod(files, location, owner, "/pkg.Service/Method")` | `runnable.PackageFromOpenAPIOperation(document, location, owner, "POST", "/path")` | +| Default with nothing added | `runnable.DefaultOperation(method, authority, audience)`, from `authority.hop.v1.policy` | `runnable.DefaultRouteOperation(method, path, authority, audience, name, description)`; `runnable.RouteMarker` writes it as the marker from the route's `x-codefly-authority-policy` JSON, which is how solution-runtime-go marks a route | +| Source of the contract | the method's request and response descriptors | the operation's `application/json` request body — a `GET`'s query — and its one `2xx` response | +| Builder | `runnable.PackageFromMethod(files, location, owner, "/pkg.Service/Method")` | `runnable.PackageFromOpenAPIOperation(document, location, owner, "POST", "/path")`, from OpenAPI 3 or Swagger 2.0 | | Projection | `runnable.ProjectMessage(md)` | `runnable.ProjectJSONSchema(doc, schema)` | | `operation` recorded | `/pkg.Service/Method` | `POST /path` | -| `input_message` / `output_message` | the messages' full names | the payload components' names | +| `input_message` / `output_message` | the messages' full names | the payload components' names; a `GET`'s query is recorded by its `operationId` | | `retryable_codes` | `google.rpc.Code` names, such as `UNAVAILABLE` | HTTP statuses as decimal strings, such as `"503"` | Both land in the same place, which is the point: one profile, one digest and @@ -580,11 +581,26 @@ taken over. `runnable.PackageFromOpenAPIOperation(document, location, owner, method, path)` reads the OpenAPI JSON an owner already publishes (`codefly generate contracts` writes one per HTTP endpoint) and derives the same package. An -operation is eligible only if it is a `POST` or a `PUT`, carries no parameter -outside `header` and `cookie`, has one `application/json` request body and -exactly one `2xx` response with an `application/json` schema. Anything else is a -refusal that names why, never a coercion: v1 keeps an operation's input as one -JSON object, and folding a parameter into it is a later profile decision. +operation is eligible only if it is a `GET`, a `POST` or a `PUT` on one fixed +path (no template), carries no parameter outside `header` and `cookie`, has one +`application/json` request body and exactly one `2xx` response with an +`application/json` schema. Anything else is a refusal that names why, never a +coercion: v1 keeps an operation's input as one JSON object. + +**A `GET` is a read, and its query is its input.** It is admitted only when +every half of its operation reads: it recomputes (`RECOVERY_RECOMPUTE`), its +tool is `EFFECT_READ_ONLY`, and its invoke scopes require nothing but `read`, so +the capability a call presents cannot change what it reads whatever the route +does. A read is the safest operation to re-run, so this is no loosening of the +retry rule; a mutation keeps its body, its receipt and its `POST` or `PUT`. A +`GET` carries no body: its input object's members are its `query` parameters, +each a scalar (string, integer, number or boolean), optional exactly when the +parameter is, and a `GET` with no parameter takes the empty object. A caller +sends the object as the query — each member as one parameter of that name, a +string as written, a number or a boolean as its JSON spelling. No component +describes a query, so `input_message` records the identity the document +publishes it under, the operation's `operationId`, and a `GET` without one is +refused. The parameter locations are an **allow list**, not a pair of denied ones. The transport carries a header and a cookie, so they are no part of the payload; @@ -593,14 +609,23 @@ Denying only `path` and `query` would let `body` and `formData` — which is how Swagger 2.0 spells a request body — pass in silence, and the derived contract would simply omit the operation's input. -The document's version is checked rather than assumed, for the same reason. -Swagger 2.0 puts a request body in an `in: body` parameter and a response schema -on the response itself, so reading one as OpenAPI 3 finds no `requestBody` and -reports exactly that — pointing at the wrong part of a document that plainly -declares one. Core's own `OpenAPICombinator` still writes 2.0, so this is a -document that turns up; it is refused as Swagger, by name. - -Both payload schemas must be `#/components/schemas/…` references. That is where +The document's version is checked rather than assumed, and **both dialects an +owner publishes are read as themselves**. Swagger 2.0 is what +protoc-gen-openapiv2, core's own `OpenAPICombinator` and solution-runtime-go's +interface artifact write, so it is the document a solution actually commits. It +spells the same operation in other places — the request body is an `in: body` +parameter, a response's schema hangs off the response, the media types are +listed in `consumes` and `produces` (the operation's, or else the document's), +a parameter states `type` beside its name, and schemas live under +`#/definitions/` — and `ParseOpenAPIDocument` moves each into the place OpenAPI +3 spells it before anything else reads the document. Every rule after that reads +both alike, so a Swagger body in two encodings or a `formData` parameter is +refused by the same rule, in the same words, as its OpenAPI 3 spelling, and the +same operation in either dialect derives a byte-identical package. Any other +`swagger` version, or a document declaring both, is refused by name. + +Both payload schemas must be references to the document's own schemas +(`#/components/schemas/…`, or `#/definitions/…` in Swagger 2.0). That is where `input_message` and `output_message` come from, and their job is to record the owner's published message identity so the reuse is auditable rather than implied by a matching shape — which an inline schema has nothing to record. A diff --git a/generated/go/codefly/runnable/v0/prepared.pb.go b/generated/go/codefly/runnable/v0/prepared.pb.go index a5ab70c6..3562b744 100644 --- a/generated/go/codefly/runnable/v0/prepared.pb.go +++ b/generated/go/codefly/runnable/v0/prepared.pb.go @@ -158,8 +158,10 @@ func (x *ConnectProcedure) GetProcedure() string { // body would be a field the owner never described. type HTTPRoute struct { state protoimpl.MessageState `protogen:"open.v1"` - // verb is the request method, POST or PUT — the two an operation applying a - // retryable effect is derived from. + // verb is the request method: POST or PUT, which carry the input as the + // JSON body, or GET, which core derives only for a read and which carries + // the input object as its query — each member one parameter of that name, a + // string as written, a number or a boolean in its JSON spelling. Verb string `protobuf:"bytes,1,opt,name=verb,proto3" json:"verb,omitempty"` // path is the route the owner publishes, beginning with "/". Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` @@ -521,9 +523,9 @@ const file_codefly_runnable_v0_prepared_proto_rawDesc = "" + "\bspelling\x18\x04 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\bspelling\"l\n" + "\x10ConnectProcedure\x12X\n" + - "\tprocedure\x18\x01 \x01(\tB:\xbaH7r5\x10\x0321^/[A-Za-z_][A-Za-z0-9_.]*/[A-Za-z_][A-Za-z0-9_]*$R\tprocedure\"Q\n" + - "\tHTTPRoute\x12$\n" + - "\x04verb\x18\x01 \x01(\tB\x10\xbaH\rr\vR\x04POSTR\x03PUTR\x04verb\x12\x1e\n" + + "\tprocedure\x18\x01 \x01(\tB:\xbaH7r5\x10\x0321^/[A-Za-z_][A-Za-z0-9_.]*/[A-Za-z_][A-Za-z0-9_]*$R\tprocedure\"V\n" + + "\tHTTPRoute\x12)\n" + + "\x04verb\x18\x01 \x01(\tB\x15\xbaH\x12r\x10R\x03GETR\x04POSTR\x03PUTR\x04verb\x12\x1e\n" + "\x04path\x18\x02 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01:\x01/R\x04path\"\xd5\x01\n" + "\fPreparedCall\x12<\n" + diff --git a/generated/python/codefly-cli/codefly_cli/codefly/runnable/v0/prepared_pb2.py b/generated/python/codefly-cli/codefly_cli/codefly/runnable/v0/prepared_pb2.py index 3eba3103..baf9d839 100644 --- a/generated/python/codefly-cli/codefly_cli/codefly/runnable/v0/prepared_pb2.py +++ b/generated/python/codefly-cli/codefly_cli/codefly/runnable/v0/prepared_pb2.py @@ -27,7 +27,7 @@ from codefly.runnable.v0 import options_pb2 as codefly_dot_runnable_dot_v0_dot_options__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"codefly/runnable/v0/prepared.proto\x12\x13\x63odefly.runnable.v0\x1a\x1b\x62uf/validate/validate.proto\x1a\x1e\x63odefly/base/v0/runnable.proto\x1a!codefly/runnable/v0/options.proto\"\xaa\x01\n\x11PreparedOperation\x12!\n\x06module\x18\x01 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x19R\x06module\x12#\n\x07service\x18\x02 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x19R\x07service\x12%\n\x08\x65ndpoint\x18\x03 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x14R\x08\x65ndpoint\x12&\n\x08spelling\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08spelling\"l\n\x10\x43onnectProcedure\x12X\n\tprocedure\x18\x01 \x01(\tB:\xbaH7r5\x10\x03\x32\x31^/[A-Za-z_][A-Za-z0-9_.]*/[A-Za-z_][A-Za-z0-9_]*$R\tprocedure\"Q\n\tHTTPRoute\x12$\n\x04verb\x18\x01 \x01(\tB\x10\xbaH\rr\x0bR\x04POSTR\x03PUTR\x04verb\x12\x1e\n\x04path\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01:\x01/R\x04path\"\xd5\x01\n\x0cPreparedCall\x12<\n\x07\x61\x64\x64ress\x18\x01 \x01(\tB\"\xbaH\x1fr\x1d\x18\x80\x04\x32\x18^https?://[^[:space:]]+$R\x07\x61\x64\x64ress\x12\x41\n\x07\x63onnect\x18\x02 \x01(\x0b\x32%.codefly.runnable.v0.ConnectProcedureH\x00R\x07\x63onnect\x12\x34\n\x04rest\x18\x03 \x01(\x0b\x32\x1e.codefly.runnable.v0.HTTPRouteH\x00R\x04restB\x0e\n\x05route\x12\x05\xbaH\x02\x08\x01\"\xa9\x03\n\x0fPreparedBinding\x12;\n\x06schema\x18\x01 \x01(\tB#\xbaH r\x1e\n\x1c\x63odefly.runnable-prepared/v3R\x06schema\x12L\n\toperation\x18\x02 \x01(\x0b\x32&.codefly.runnable.v0.PreparedOperationB\x06\xbaH\x03\xc8\x01\x01R\toperation\x12=\n\x04\x63\x61ll\x18\x03 \x01(\x0b\x32!.codefly.runnable.v0.PreparedCallB\x06\xbaH\x03\xc8\x01\x01R\x04\x63\x61ll\x12\x45\n\x08\x63ontract\x18\x04 \x01(\x0b\x32!.codefly.base.v0.RunnableContractB\x06\xbaH\x03\xc8\x01\x01R\x08\x63ontract\x12\x45\n\x0f\x63ontract_digest\x18\x05 \x01(\tB\x1c\xbaH\x19r\x17\x32\x15^sha256:[0-9a-f]{64}$R\x0e\x63ontractDigest\x12>\n\x06policy\x18\x06 \x01(\x0b\x32\x1e.codefly.runnable.v0.OperationB\x06\xbaH\x03\xc8\x01\x01R\x06policy\"\xa5\x02\n\x0eResolvedPolicy\x12\x42\n\x06schema\x18\x01 \x01(\tB*\xbaH\'r%\n#codefly.runnable-resolved-policy/v1R\x06schema\x12L\n\toperation\x18\x02 \x01(\x0b\x32&.codefly.runnable.v0.PreparedOperationB\x06\xbaH\x03\xc8\x01\x01R\toperation\x12>\n\x06policy\x18\x03 \x01(\x0b\x32\x1e.codefly.runnable.v0.OperationB\x06\xbaH\x03\xc8\x01\x01R\x06policy\x12\x41\n\rpolicy_digest\x18\x04 \x01(\tB\x1c\xbaH\x19r\x17\x32\x15^sha256:[0-9a-f]{64}$R\x0cpolicyDigestb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"codefly/runnable/v0/prepared.proto\x12\x13\x63odefly.runnable.v0\x1a\x1b\x62uf/validate/validate.proto\x1a\x1e\x63odefly/base/v0/runnable.proto\x1a!codefly/runnable/v0/options.proto\"\xaa\x01\n\x11PreparedOperation\x12!\n\x06module\x18\x01 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x19R\x06module\x12#\n\x07service\x18\x02 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x19R\x07service\x12%\n\x08\x65ndpoint\x18\x03 \x01(\tB\t\xbaH\x06r\x04\x10\x03\x18\x14R\x08\x65ndpoint\x12&\n\x08spelling\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08spelling\"l\n\x10\x43onnectProcedure\x12X\n\tprocedure\x18\x01 \x01(\tB:\xbaH7r5\x10\x03\x32\x31^/[A-Za-z_][A-Za-z0-9_.]*/[A-Za-z_][A-Za-z0-9_]*$R\tprocedure\"V\n\tHTTPRoute\x12)\n\x04verb\x18\x01 \x01(\tB\x15\xbaH\x12r\x10R\x03GETR\x04POSTR\x03PUTR\x04verb\x12\x1e\n\x04path\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01:\x01/R\x04path\"\xd5\x01\n\x0cPreparedCall\x12<\n\x07\x61\x64\x64ress\x18\x01 \x01(\tB\"\xbaH\x1fr\x1d\x18\x80\x04\x32\x18^https?://[^[:space:]]+$R\x07\x61\x64\x64ress\x12\x41\n\x07\x63onnect\x18\x02 \x01(\x0b\x32%.codefly.runnable.v0.ConnectProcedureH\x00R\x07\x63onnect\x12\x34\n\x04rest\x18\x03 \x01(\x0b\x32\x1e.codefly.runnable.v0.HTTPRouteH\x00R\x04restB\x0e\n\x05route\x12\x05\xbaH\x02\x08\x01\"\xa9\x03\n\x0fPreparedBinding\x12;\n\x06schema\x18\x01 \x01(\tB#\xbaH r\x1e\n\x1c\x63odefly.runnable-prepared/v3R\x06schema\x12L\n\toperation\x18\x02 \x01(\x0b\x32&.codefly.runnable.v0.PreparedOperationB\x06\xbaH\x03\xc8\x01\x01R\toperation\x12=\n\x04\x63\x61ll\x18\x03 \x01(\x0b\x32!.codefly.runnable.v0.PreparedCallB\x06\xbaH\x03\xc8\x01\x01R\x04\x63\x61ll\x12\x45\n\x08\x63ontract\x18\x04 \x01(\x0b\x32!.codefly.base.v0.RunnableContractB\x06\xbaH\x03\xc8\x01\x01R\x08\x63ontract\x12\x45\n\x0f\x63ontract_digest\x18\x05 \x01(\tB\x1c\xbaH\x19r\x17\x32\x15^sha256:[0-9a-f]{64}$R\x0e\x63ontractDigest\x12>\n\x06policy\x18\x06 \x01(\x0b\x32\x1e.codefly.runnable.v0.OperationB\x06\xbaH\x03\xc8\x01\x01R\x06policy\"\xa5\x02\n\x0eResolvedPolicy\x12\x42\n\x06schema\x18\x01 \x01(\tB*\xbaH\'r%\n#codefly.runnable-resolved-policy/v1R\x06schema\x12L\n\toperation\x18\x02 \x01(\x0b\x32&.codefly.runnable.v0.PreparedOperationB\x06\xbaH\x03\xc8\x01\x01R\toperation\x12>\n\x06policy\x18\x03 \x01(\x0b\x32\x1e.codefly.runnable.v0.OperationB\x06\xbaH\x03\xc8\x01\x01R\x06policy\x12\x41\n\rpolicy_digest\x18\x04 \x01(\tB\x1c\xbaH\x19r\x17\x32\x15^sha256:[0-9a-f]{64}$R\x0cpolicyDigestb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -45,7 +45,7 @@ _globals['_CONNECTPROCEDURE'].fields_by_name['procedure']._loaded_options = None _globals['_CONNECTPROCEDURE'].fields_by_name['procedure']._serialized_options = b'\272H7r5\020\00321^/[A-Za-z_][A-Za-z0-9_.]*/[A-Za-z_][A-Za-z0-9_]*$' _globals['_HTTPROUTE'].fields_by_name['verb']._loaded_options = None - _globals['_HTTPROUTE'].fields_by_name['verb']._serialized_options = b'\272H\rr\013R\004POSTR\003PUT' + _globals['_HTTPROUTE'].fields_by_name['verb']._serialized_options = b'\272H\022r\020R\003GETR\004POSTR\003PUT' _globals['_HTTPROUTE'].fields_by_name['path']._loaded_options = None _globals['_HTTPROUTE'].fields_by_name['path']._serialized_options = b'\272H\007r\005\020\001:\001/' _globals['_PREPAREDCALL'].oneofs_by_name['route']._loaded_options = None @@ -77,11 +77,11 @@ _globals['_CONNECTPROCEDURE']._serialized_start=328 _globals['_CONNECTPROCEDURE']._serialized_end=436 _globals['_HTTPROUTE']._serialized_start=438 - _globals['_HTTPROUTE']._serialized_end=519 - _globals['_PREPAREDCALL']._serialized_start=522 - _globals['_PREPAREDCALL']._serialized_end=735 - _globals['_PREPAREDBINDING']._serialized_start=738 - _globals['_PREPAREDBINDING']._serialized_end=1163 - _globals['_RESOLVEDPOLICY']._serialized_start=1166 - _globals['_RESOLVEDPOLICY']._serialized_end=1459 + _globals['_HTTPROUTE']._serialized_end=524 + _globals['_PREPAREDCALL']._serialized_start=527 + _globals['_PREPAREDCALL']._serialized_end=740 + _globals['_PREPAREDBINDING']._serialized_start=743 + _globals['_PREPAREDBINDING']._serialized_end=1168 + _globals['_RESOLVEDPOLICY']._serialized_start=1171 + _globals['_RESOLVEDPOLICY']._serialized_end=1464 # @@protoc_insertion_point(module_scope) diff --git a/proto/codefly/runnable/v0/prepared.proto b/proto/codefly/runnable/v0/prepared.proto index 865adf6d..582b5636 100644 --- a/proto/codefly/runnable/v0/prepared.proto +++ b/proto/codefly/runnable/v0/prepared.proto @@ -55,9 +55,11 @@ message ConnectProcedure { // owner's schema declares. No Codefly envelope: a field Codefly added to that // body would be a field the owner never described. message HTTPRoute { - // verb is the request method, POST or PUT — the two an operation applying a - // retryable effect is derived from. - string verb = 1 [(buf.validate.field).string.in = "POST", (buf.validate.field).string.in = "PUT"]; + // verb is the request method: POST or PUT, which carry the input as the + // JSON body, or GET, which core derives only for a read and which carries + // the input object as its query — each member one parameter of that name, a + // string as written, a number or a boolean in its JSON spelling. + string verb = 1 [(buf.validate.field).string.in = "GET", (buf.validate.field).string.in = "POST", (buf.validate.field).string.in = "PUT"]; // path is the route the owner publishes, beginning with "/". string path = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.prefix = "/"]; } diff --git a/runnable/operation_openapi.go b/runnable/operation_openapi.go index b45374bb..8cef596a 100644 --- a/runnable/operation_openapi.go +++ b/runnable/operation_openapi.go @@ -1,6 +1,7 @@ package runnable import ( + "bytes" "encoding/json" "fmt" "mime" @@ -8,6 +9,7 @@ import ( "slices" "strings" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/durationpb" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -16,12 +18,15 @@ import ( ) const ( - // componentSchemaPrefix is the only $ref the projection follows. A $ref - // anywhere else in the operation — its request body, a response, a - // parameter — is refused rather than resolved: core reads the operation as - // the document writes it, and the payload schemas are where the issue of - // one shape described once actually arises. - componentSchemaPrefix = "#/components/schemas/" + // componentSchemaPrefix and definitionSchemaPrefix are the only $refs the + // projection follows, one per dialect: OpenAPI 3 keeps its schemas under + // components and Swagger 2.0 under definitions. A $ref anywhere else in the + // operation — its request body, a response, a parameter — is refused rather + // than resolved: core reads the operation as the document writes it, and the + // payload schemas are where the issue of one shape described once actually + // arises. + componentSchemaPrefix = "#/components/schemas/" + definitionSchemaPrefix = "#/definitions/" // jsonMediaType is the one media type a derived operation carries. The // bounded profile is a JSON profile, so a route offering another encoding // offers a payload the contract cannot describe. @@ -29,20 +34,37 @@ const ( ) // openAPIOperationMethods are the HTTP methods a Runnable operation may be -// published under. An operation applies an effect and is retried under a -// declared policy, which GET and DELETE do not describe and PATCH describes as -// a diff against a state the contract does not carry. -var openAPIOperationMethods = []string{http.MethodPost, http.MethodPut} +// published under. POST and PUT carry the input as a JSON body and may apply +// any effect. GET is a read: it is admitted for an operation that recomputes, +// presents a read-only tool and is invoked holding nothing but read +// (readOnlyRoute), and its input is its query (queryPayload) — it is the safest +// operation to re-run, not one outside the profile. DELETE describes an effect +// with no body to name its input, and PATCH a diff against a state the contract +// does not carry. +var openAPIOperationMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut} // transportParameterLocations are the parameter locations a derived operation // may carry: the transport carries them and they are no part of the payload. // Every other location names an input the bounded contract would have to -// describe, and is refused rather than ignored. +// describe, and is refused rather than ignored — except `query` on a GET, +// whose query is its whole input (queryPayload). var transportParameterLocations = []string{"header", "cookie"} -// supportedOpenAPIMajor is the document version a Runnable operation is derived -// from. -const supportedOpenAPIMajor = "3" +// queryParameterLocation is where a GET's input travels. +const queryParameterLocation = "query" + +// The document versions a Runnable operation is derived from: OpenAPI 3, and +// Swagger 2.0, the dialect protoc-gen-openapiv2, core's own OpenAPICombinator +// and solution-runtime-go's interface artifact write. Swagger spells the same +// operation in other places — a body is an `in: body` parameter, a response +// schema hangs off the response itself, media types are listed once in +// consumes and produces — and the reader moves each into the place OpenAPI 3 +// spells it before anything else reads the document (fromSwagger), so one +// derivation reads both. +const ( + supportedOpenAPIMajor = "3" + supportedSwagger = "2.0" +) // OpenAPIDocument is the part of an OpenAPI document a Runnable operation is // derived from, read as JSON. It is deliberately not a model of OpenAPI: @@ -50,12 +72,19 @@ const supportedOpenAPIMajor = "3" // examples — says how the route is served and never what the contract is. type OpenAPIDocument struct { // OpenAPI and Swagger are the two spellings of a document's version. Both - // are read so a Swagger 2.0 document is refused as one rather than as an + // are read so a Swagger 2.0 document is read as one rather than as an // OpenAPI 3 document that happens to declare nothing. OpenAPI string `json:"openapi"` Swagger string `json:"swagger"` Paths map[string]*OpenAPIPathItem `json:"paths"` Components OpenAPIComponents `json:"components"` + // Definitions, Consumes and Produces are Swagger 2.0's: its schemas, and + // the media types every operation carries unless it lists its own. + Definitions map[string]*JSONSchema `json:"definitions"` + Consumes []string `json:"consumes"` + Produces []string `json:"produces"` + // schemaPrefix is where this document's payload $refs point. + schemaPrefix string } // OpenAPIComponents holds the schemas a payload $ref names. @@ -70,6 +99,7 @@ type OpenAPIPathItem struct { // Parameters are declared for every operation on the path, so they are as // much a part of one operation's shape as the ones it declares itself. Parameters []OpenAPIParameter `json:"parameters"` + Get *OpenAPIOperation `json:"get"` Post *OpenAPIOperation `json:"post"` Put *OpenAPIOperation `json:"put"` } @@ -87,20 +117,31 @@ type OpenAPIOperation struct { OperationID string `json:"operationId"` Summary string `json:"summary"` Description string `json:"description"` + // Consumes and Produces are Swagger 2.0's per-operation media types. + Consumes []string `json:"consumes"` + Produces []string `json:"produces"` } -// OpenAPIParameter is an input carried outside the request body. +// OpenAPIParameter is an input carried outside the request body. OpenAPI 3 +// describes its value with a schema; Swagger 2.0 with type and format beside +// the name, or — for `in: body` — with a schema that is the request body's. type OpenAPIParameter struct { - Ref string `json:"$ref"` - Name string `json:"name"` - In string `json:"in"` + Ref string `json:"$ref"` + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema *JSONSchema `json:"schema"` + Type string `json:"type"` + Format string `json:"format"` } // OpenAPIBody is a request body or a response: the media types it is carried -// as, and the schema of each. +// as, and the schema of each. Schema is Swagger 2.0's response schema, which +// the reader moves into Content. type OpenAPIBody struct { Ref string `json:"$ref"` Content map[string]OpenAPIMediaType `json:"content"` + Schema *JSONSchema `json:"schema"` } // OpenAPIMediaType is one encoding of a body. @@ -117,24 +158,90 @@ func ParseOpenAPIDocument(document []byte) (*OpenAPIDocument, error) { return nil, fmt.Errorf("%w: document is not readable OpenAPI JSON: %v", ErrInvalid, err) } // The version is checked rather than assumed. Swagger 2.0 describes the - // same operation in different places — a body is an `in: body` parameter - // and a response schema hangs off the response itself — so reading one as - // OpenAPI 3 finds no request body and reports that, pointing at the wrong - // part of a document that plainly declares one. Core's own - // OpenAPICombinator still writes 2.0, so this is a document that turns up. + // same operation in different places, so reading one as OpenAPI 3 would + // find no request body and report that, pointing at the wrong part of a + // document that plainly declares one. It is read as itself instead. if doc.Swagger != "" { - return nil, fmt.Errorf("%w: document declares swagger %q; a Runnable operation is derived from OpenAPI %s, which spells a request body and a response schema in different places", ErrInvalid, doc.Swagger, supportedOpenAPIMajor) + if doc.Swagger != supportedSwagger || doc.OpenAPI != "" { + return nil, fmt.Errorf("%w: document declares swagger %q; a Runnable operation is derived from swagger %s or OpenAPI %s", ErrInvalid, doc.Swagger, supportedSwagger, supportedOpenAPIMajor) + } + doc.fromSwagger() + return doc, nil } if major, _, _ := strings.Cut(doc.OpenAPI, "."); major != supportedOpenAPIMajor { return nil, fmt.Errorf("%w: document declares openapi %q; a Runnable operation is derived from OpenAPI %s", ErrInvalid, doc.OpenAPI, supportedOpenAPIMajor) } + doc.schemaPrefix = componentSchemaPrefix return doc, nil } -// componentSchema resolves a $ref against the document's own components. +// fromSwagger moves what Swagger 2.0 spells differently into the place OpenAPI +// 3 spells it, and nothing else: an `in: body` parameter becomes the request +// body, a response's schema its content, each carried as the media types the +// operation (or else the document) lists, and a typed parameter's type and +// format its schema. Every rule after this — one JSON media type, one 2xx, the +// parameter allow-list — then reads both dialects alike, so a Swagger body in +// two encodings or a formData parameter is refused by the same rule, in the +// same words, as its OpenAPI 3 spelling. +func (d *OpenAPIDocument) fromSwagger() { + d.schemaPrefix = definitionSchemaPrefix + d.Components.Schemas = d.Definitions + carried := func(schema *JSONSchema, listed, document []string) map[string]OpenAPIMediaType { + types := listed + if len(types) == 0 { + types = document + } + content := make(map[string]OpenAPIMediaType, len(types)) + for _, kind := range types { + content[kind] = OpenAPIMediaType{Schema: schema} + } + return content + } + for _, item := range d.Paths { + if item == nil { + continue + } + item.Parameters = typedParameters(item.Parameters) + for _, operation := range []*OpenAPIOperation{item.Get, item.Post, item.Put} { + if operation == nil { + continue + } + kept := make([]OpenAPIParameter, 0, len(operation.Parameters)) + for _, parameter := range operation.Parameters { + if parameter.In == "body" && parameter.Ref == "" && operation.RequestBody == nil { + operation.RequestBody = &OpenAPIBody{Content: carried(parameter.Schema, operation.Consumes, d.Consumes)} + continue + } + kept = append(kept, parameter) + } + operation.Parameters = typedParameters(kept) + for _, response := range operation.Responses { + if response != nil && response.Schema != nil && response.Content == nil { + response.Content = carried(response.Schema, operation.Produces, d.Produces) + } + } + } + } +} + +// typedParameters gives each Swagger parameter that states its value by type +// the schema OpenAPI 3 would state it with. +func typedParameters(parameters []OpenAPIParameter) []OpenAPIParameter { + for i := range parameters { + if parameters[i].Schema == nil && parameters[i].Type != "" { + parameters[i].Schema = &JSONSchema{Type: JSONSchemaType{parameters[i].Type}, Format: parameters[i].Format} + } + } + return parameters +} + +// componentSchema resolves a $ref against the document's own schemas. func (d *OpenAPIDocument) componentSchema(ref string) (*JSONSchema, bool) { - name, isComponent := strings.CutPrefix(ref, componentSchemaPrefix) - if d == nil || !isComponent { + if d == nil { + return nil, false + } + name, isComponent := strings.CutPrefix(ref, d.schemaPrefix) + if !isComponent { return nil, false } // A component spelled as null is a key the map reports as present carrying @@ -152,13 +259,19 @@ func Route(method, path string) string { // operation finds the operation a route publishes and refuses one whose shape // a bounded contract cannot describe. Each refusal names what it refused and -// why: v1 keeps an operation's input as one JSON object, and folding a path or -// query parameter into it is a later profile decision rather than a coercion -// core may make now. +// why: v1 keeps an operation's input as one JSON object — a POST or PUT's +// body, a GET's query — and folding a path parameter or a query beside a body +// into it is a later profile decision rather than a coercion core may make now. func (d *OpenAPIDocument) operation(method, path string) (*OpenAPIOperation, string, error) { route := Route(method, path) - if !slices.Contains(openAPIOperationMethods, strings.ToUpper(method)) { - return nil, "", fmt.Errorf("%w: %s is not %s, and only those apply an effect a declared policy can retry", ErrInvalid, route, strings.Join(openAPIOperationMethods, " or ")) + verb := strings.ToUpper(method) + if !slices.Contains(openAPIOperationMethods, verb) { + return nil, "", fmt.Errorf("%w: %s is not %s, and only those carry an input a declared policy can retry", ErrInvalid, route, joinedMethods()) + } + // A template names an input the route carries in its path, which no + // parameter list need declare for the document to look complete. + if strings.ContainsAny(path, "{}") { + return nil, "", fmt.Errorf("%w: %s is a path template, and a derived operation's route is one fixed path", ErrInvalid, route) } item, published := d.Paths[path] if !published || item == nil { @@ -167,10 +280,7 @@ func (d *OpenAPIDocument) operation(method, path string) (*OpenAPIOperation, str if item.Ref != "" { return nil, "", fmt.Errorf("%w: %s is a $ref to another path item, which core does not follow", ErrInvalid, path) } - operation := item.Post - if strings.EqualFold(method, http.MethodPut) { - operation = item.Put - } + operation := map[string]*OpenAPIOperation{http.MethodGet: item.Get, http.MethodPost: item.Post, http.MethodPut: item.Put}[verb] if operation == nil { return nil, "", fmt.Errorf("%w: the document publishes no %s", ErrInvalid, route) } @@ -183,13 +293,90 @@ func (d *OpenAPIDocument) operation(method, path string) (*OpenAPIOperation, str // input: `body` and `formData` are how Swagger 2.0 spells a request // body, so denying only path and query would drop the operation's whole // input from the contract without saying so. + if verb == http.MethodGet && parameter.In == queryParameterLocation { + continue + } if !slices.Contains(transportParameterLocations, parameter.In) { - return nil, "", fmt.Errorf("%w: %s carries %q parameter %q, and a derived operation's input is the JSON request body alone", ErrInvalid, route, parameter.In, parameter.Name) + return nil, "", fmt.Errorf("%w: %s carries %q parameter %q, and a derived operation's input is the JSON request body alone, or a GET's query", ErrInvalid, route, parameter.In, parameter.Name) } } return operation, route, nil } +// joinedMethods spells the admitted methods for a refusal: "GET, POST or PUT". +func joinedMethods() string { + last := len(openAPIOperationMethods) - 1 + return strings.Join(openAPIOperationMethods[:last], ", ") + " or " + openAPIOperationMethods[last] +} + +// readOnlyRoute holds a GET to being a read in every half of its operation: it +// recomputes, so an uncertain outcome is asked again rather than resolved by a +// receipt; its tool, when it states one, says it only reads (a recomputed +// operation stating none is presented as a read, preparedTool); and it is +// invoked holding nothing but +// the read action, so the capability a call presents cannot change what it +// reads whatever the route does. A mutation keeps its body, its receipt and its +// POST or PUT. +func readOnlyRoute(spec *OperationSpec, route string) error { + invoked := &Authority{Requires: spec.InvokeScopes} + if spec.EffectiveRecovery() != basev0.RunnableExecution_RECOVERY_RECOMPUTE || + (spec.Tool != nil && spec.Tool.GetEffect() != runnablev0.ToolExposure_EFFECT_READ_ONLY) || !invoked.ReadOnly() { + return fmt.Errorf("%w: %s is a GET, which is admitted only as a read: recovery %s, a %s tool, and invoke scopes requiring only %q; a mutation is published as POST or PUT", + ErrInvalid, route, basev0.RunnableExecution_RECOVERY_RECOMPUTE, runnablev0.ToolExposure_EFFECT_READ_ONLY, ReadOnlyScopeAction) + } + return nil +} + +// queryPayload is a GET's input: one JSON object whose members are its query +// parameters, each a scalar a query string spells, optional exactly when the +// parameter is. A caller carries the object as the query — each member as one +// parameter of that name, a string as written, a number or a boolean as its +// JSON spelling — and a GET with no parameter takes the empty object. No +// component describes a query, so the identity input_message records is the +// one the document publishes it under: the operation's operationId. +func (d *OpenAPIDocument) queryPayload(operation *OpenAPIOperation, parameters []OpenAPIParameter, route string) (*basev0.RunnableSchema, string, error) { + if operation.RequestBody != nil { + return nil, "", fmt.Errorf("%w: %s declares a request body, and a GET's input is its query", ErrInvalid, route) + } + if operation.OperationID == "" { + return nil, "", fmt.Errorf("%w: %s declares no operationId, which is the published identity a GET's query is recorded by", ErrInvalid, route) + } + properties := JSONSchemaProperties{} + var required []string + for _, parameter := range parameters { + if parameter.In != queryParameterLocation { + continue + } + if parameter.Schema == nil { + return nil, "", fmt.Errorf("%w: %s query parameter %q declares no schema", ErrInvalid, route, parameter.Name) + } + if slices.ContainsFunc(properties, func(p JSONSchemaProperty) bool { return p.Name == parameter.Name }) { + return nil, "", fmt.Errorf("%w: %s declares query parameter %q twice", ErrInvalid, route, parameter.Name) + } + properties = append(properties, JSONSchemaProperty{Name: parameter.Name, Schema: parameter.Schema}) + if parameter.Required { + required = append(required, parameter.Name) + } + } + input, err := ProjectJSONSchema(d, &JSONSchema{ + Type: JSONSchemaType{"object"}, Properties: &properties, Required: required, AdditionalProperties: json.RawMessage("false"), + }) + if err != nil { + return nil, "", err + } + for _, field := range input.GetFields() { + switch field.GetType() { + case basev0.RunnableField_STRING, basev0.RunnableField_INTEGER, basev0.RunnableField_NUMBER, basev0.RunnableField_BOOLEAN: + default: + return nil, "", fmt.Errorf("%w: %s query parameter %q is %s, and a query carries one scalar per name", ErrInvalid, route, field.GetName(), field.GetType()) + } + if field.GetNullable() { + return nil, "", fmt.Errorf("%w: %s query parameter %q is nullable, which a query string cannot spell", ErrInvalid, route, field.GetName()) + } + } + return input, operation.OperationID, nil +} + // payload projects one body and names the component it was described by. The // schema has to be a component $ref: input_message and output_message record // the owner's published message identity so the reuse is auditable, and an @@ -219,9 +406,9 @@ func (d *OpenAPIDocument) payload(body *OpenAPIBody, route, what string) (*basev if media.Schema == nil { return nil, "", fmt.Errorf("%w: %s %s declares no schema, and an undescribed payload is not a contract", ErrInvalid, route, what) } - name, isComponent := strings.CutPrefix(media.Schema.Ref, componentSchemaPrefix) + name, isComponent := strings.CutPrefix(media.Schema.Ref, d.schemaPrefix) if !isComponent || !onlyRef(media.Schema) { - return nil, "", fmt.Errorf("%w: %s %s schema is not a %s reference, and a payload described inline has no published identity to record", ErrInvalid, route, what, componentSchemaPrefix) + return nil, "", fmt.Errorf("%w: %s %s schema is not a %s reference, and a payload described inline has no published identity to record", ErrInvalid, route, what, d.schemaPrefix) } schema, err := ProjectJSONSchema(d, media.Schema) if err != nil { @@ -255,6 +442,9 @@ func success(operation *OpenAPIOperation, route string) (*OpenAPIBody, error) { // operation a REST route declares, and the execution policy and authority that // are installed beside it rather than digested into it. // +// method is GET, POST or PUT; a GET is admitted as a read only, its query its +// input (readOnlyRoute, queryPayload). document is OpenAPI 3 or Swagger 2.0. +// // It is PackageFromMethod's twin for a service described by OpenAPI rather than // by a descriptor, and deliberately lands in the same place: one profile, one // digest and one drift rule, so a FastAPI service and a gRPC one publish the @@ -281,7 +471,16 @@ func PackageFromOpenAPIOperation(document []byte, location *resources.RunnableLo return nil, nil, err } } - input, inputMessage, err := doc.payload(operation.RequestBody, route, "request body") + var input *basev0.RunnableSchema + var inputMessage string + if strings.EqualFold(method, http.MethodGet) { + if err = readOnlyRoute(spec, route); err != nil { + return nil, nil, err + } + input, inputMessage, err = doc.queryPayload(operation, slices.Concat(doc.Paths[path].Parameters, operation.Parameters), route) + } else { + input, inputMessage, err = doc.payload(operation.RequestBody, route, "request body") + } if err != nil { return nil, nil, err } @@ -348,3 +547,110 @@ func restTool(operation *OpenAPIOperation, route string, recovery basev0.Runnabl } return &runnablev0.ToolExposure{Name: toolName(name), Description: description, Effect: effect} } + +// DefaultReadRetryableStatuses are the outcomes a derived REST read is retried +// on: DefaultReadRetryableCodes, spelled as the HTTP status it is. +var DefaultReadRetryableStatuses = []string{"503"} + +// DefaultRouteOperation is DefaultOperation for a REST route: the operation a +// route declaring an authority policy derives with nothing added, which its +// owner publishes as the route's x-codefly-operation marker so the marker +// states core's defaults rather than the owner's guess at them. name and +// description present it as a tool — the route's operationId and its one-line +// summary — and audience is the trust boundary the owner's hop admits. +// +// A read recomputes and may be served over GET, POST or PUT; anything else is +// a mutation resolved by its receipt and may not be a GET. A policy requiring +// nothing, a mutation over GET, or a path template (a relayed subtree, whose +// input is partly its path) is ErrNotDerivable: the route stays a call its own +// clients make. +func DefaultRouteOperation(method, path string, authority *Authority, audience, name, description string) (*OperationSpec, error) { + route := Route(method, path) + if authority == nil || len(authority.Requires) == 0 { + return nil, fmt.Errorf("%w: %s requires no authority, so there is nothing to mint the call's capability from", ErrNotDerivable, route) + } + if strings.ContainsAny(path, "{}") { + return nil, fmt.Errorf("%w: %s is a path template, and an operation's route is one fixed path", ErrNotDerivable, route) + } + if strings.EqualFold(method, http.MethodGet) && !authority.ReadOnly() { + return nil, fmt.Errorf("%w: %s requires more than %q, and a GET is derived only as a read", ErrNotDerivable, route, ReadOnlyScopeAction) + } + spec := &OperationSpec{ + Method: route, + AttemptTimeout: DefaultAttemptTimeout, + TotalTimeout: DefaultTotalTimeout, + Backoff: DefaultBackoff, + Codes: HTTPStatusCodes, + Audience: audience, + Completion: basev0.RunnableExecution_COMPLETION_CALL, + InvokeScopes: clonedScopes(authority.Requires), + } + effect := runnablev0.ToolExposure_EFFECT_MUTATION + if authority.ReadOnly() { + effect = runnablev0.ToolExposure_EFFECT_READ_ONLY + spec.Recovery = basev0.RunnableExecution_RECOVERY_RECOMPUTE + spec.MaxAttempts = DefaultReadAttempts + spec.RetryableCodes = slices.Clone(DefaultReadRetryableStatuses) + } else { + spec.Recovery = basev0.RunnableExecution_RECOVERY_RECEIPT + spec.MaxAttempts = DefaultMutationAttempts + } + if name == "" { + name = route + } + text := toolDescription(description) + if text == "" { + text = "Calls " + route + "." + } + spec.Tool = &runnablev0.ToolExposure{Name: toolName(name), Description: text, Effect: effect} + if err := spec.Validate(); err != nil { + return nil, err + } + return spec, nil +} + +// routePolicy is an authority.hop.v1.Policy as an owner publishes it under a +// route's x-codefly-authority-policy, in its proto field names. +type routePolicy struct { + Requires []struct { + ResourceKind string `json:"resource_kind"` + Actions []string `json:"actions"` + } `json:"requires"` + AnyCheckedCaller bool `json:"any_checked_caller"` +} + +// RouteMarker is the x-codefly-operation an owner publishes for a route whose +// authority policy it publishes: the marker of DefaultRouteOperation, so the +// owner hands core the policy it already writes and core states the defaults. +// policy is that policy's JSON, as written under x-codefly-authority-policy; +// a field it does not define is refused rather than read as nothing required. +// The marker is proto3 JSON in proto field names with its members in a stable +// order, because the document carrying it is committed and diffed. A route +// DefaultRouteOperation does not derive answers ErrNotDerivable. +func RouteMarker(method, path string, policy json.RawMessage, audience, name, description string) (json.RawMessage, error) { + decoder := json.NewDecoder(bytes.NewReader(policy)) + decoder.DisallowUnknownFields() + declared := routePolicy{} + if err := decoder.Decode(&declared); err != nil { + return nil, fmt.Errorf("%w: %s policy is not an authority.hop.v1.Policy: %v", ErrInvalid, Route(method, path), err) + } + authority := &Authority{AnyCheckedCaller: declared.AnyCheckedCaller} + for _, requirement := range declared.Requires { + authority.Requires = append(authority.Requires, &basev0.WorkScopeV1{ResourceKind: requirement.ResourceKind, Actions: slices.Clone(requirement.Actions)}) + } + spec, err := DefaultRouteOperation(method, path, authority, audience, name, description) + if err != nil { + return nil, err + } + written, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(spec.Policy()) + if err != nil { + return nil, err + } + // protojson's spacing is deliberately unstable; a round trip through + // encoding/json fixes the members' order and spacing. + var marker map[string]any + if err = json.Unmarshal(written, &marker); err != nil { + return nil, err + } + return json.Marshal(marker) +} diff --git a/runnable/operation_openapi_route_test.go b/runnable/operation_openapi_route_test.go new file mode 100644 index 00000000..833cd3d9 --- /dev/null +++ b/runnable/operation_openapi_route_test.go @@ -0,0 +1,322 @@ +package runnable_test + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/runnable" +) + +// swaggerDocument reads a Swagger 2.0 fixture, reshaped by edit. +func swaggerDocument(t *testing.T, name string, edit func(map[string]any)) []byte { + t.Helper() + raw, err := os.ReadFile("testdata/openapi/" + name) + require.NoError(t, err) + if edit == nil { + return raw + } + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + edit(doc) + edited, err := json.Marshal(doc) + require.NoError(t, err) + return edited +} + +func releasesOperation(doc map[string]any, path string) map[string]any { + return doc["paths"].(map[string]any)[path].(map[string]any)["get"].(map[string]any) +} + +// releasesOwner publishes the releases solution's backend on its http +// endpoint, as a solution's API catalog names it. +func releasesOwner() runnable.ServiceOwner { + owner := ingestOwner() + owner.Module, owner.Service, owner.Endpoint = "releases", "backend", "http" + return owner +} + +func releasesLocation(name string) *resources.RunnableLocation { + location := ingestLocation() + location.Identity.Module, location.Identity.Name = "releases", name + return location +} + +// TestTheTwoDialectsDeriveOneOperation is the dialect half of the drift gate: +// the same operation written as Swagger 2.0 — its body an `in: body` +// parameter, its response schema on the response, its media types listed once +// for the document, its schemas under definitions — derives byte for byte the +// package its OpenAPI 3 spelling does. Nothing in a package records which +// dialect described it, so any difference is the reader's. +func TestTheTwoDialectsDeriveOneOperation(t *testing.T) { + openAPI, openAPISpec := derivedRestIngestion(t) + swagger, swaggerSpec, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "ingestion.swagger.json", nil), ingestLocation(), ingestRestOwner(), "POST", ingestPath) + require.NoError(t, err) + require.True(t, proto.Equal(openAPI, swagger), "openapi %v\nswagger %v", openAPI, swagger) + require.Equal(t, openAPI.GetDigest(), swagger.GetDigest()) + require.True(t, proto.Equal(openAPISpec.Policy(), swaggerSpec.Policy())) +} + +// TestSwaggerIsHeldToTheSameRules shows the dialect is read, not trusted: each +// Swagger spelling of a shape the profile refuses is refused by the rule, and +// in the words, its OpenAPI 3 spelling is. +func TestSwaggerIsHeldToTheSameRules(t *testing.T) { + post := func(doc map[string]any) map[string]any { + return doc["paths"].(map[string]any)[ingestPath].(map[string]any)["post"].(map[string]any) + } + for _, test := range []struct { + name string + edit func(map[string]any) + because string + }{ + {name: "two request encodings", edit: func(doc map[string]any) { + post(doc)["consumes"] = []any{"application/json", "application/xml"} + }, because: "request body is carried as 2 media types"}, + {name: "not json", edit: func(doc map[string]any) { + doc["produces"] = []any{"application/x-protobuf"} + }, because: `response is carried as "application/x-protobuf"`}, + {name: "no media type listed", edit: func(doc map[string]any) { + delete(doc, "consumes") + }, because: "request body is carried as 0 media types"}, + {name: "form data", edit: func(doc map[string]any) { + post(doc)["parameters"] = []any{map[string]any{"name": "text", "in": "formData", "type": "string"}} + }, because: `"formData" parameter "text"`}, + {name: "no body", edit: func(doc map[string]any) { + delete(post(doc), "parameters") + }, because: "declares no request body"}, + {name: "response without schema", edit: func(doc map[string]any) { + post(doc)["responses"] = map[string]any{"200": map[string]any{"description": "applied"}} + }, because: "response is carried as 0 media types"}, + {name: "a component reference", edit: func(doc map[string]any) { + post(doc)["parameters"].([]any)[0].(map[string]any)["schema"] = map[string]any{"$ref": "#/components/schemas/ApplyTextRequest"} + }, because: "is not a #/definitions/ reference"}, + {name: "undefined definition", edit: func(doc map[string]any) { + delete(doc["definitions"].(map[string]any), "ApplyTextResponse") + }, because: "which the document does not define"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "ingestion.swagger.json", test.edit), ingestLocation(), ingestRestOwner(), "POST", ingestPath) + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, test.because) + }) + } +} + +// TestPackageFromOpenAPIOperationDerivesAGetRead covers a solution's read: a +// GET whose query is its input — each parameter a member, required exactly +// when the parameter is — recomputed and presented as a read-only tool. +func TestPackageFromOpenAPIOperationDerivesAGetRead(t *testing.T) { + pkg, spec, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "releases.swagger.json", nil), releasesLocation("backend-http-history"), releasesOwner(), "GET", "/history") + require.NoError(t, err) + require.NoError(t, runnable.VerifyPackage(pkg)) + require.Equal(t, "GET /history", spec.Method) + + require.True(t, proto.Equal(&basev0.RunnableSchema{Fields: []*basev0.RunnableField{ + {Name: "repo", Type: basev0.RunnableField_STRING}, + {Name: "component", Type: basev0.RunnableField_STRING, Optional: true}, + }}, pkg.GetContract().GetInput()), "input %v", pkg.GetContract().GetInput()) + require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, pkg.GetExecution().GetRecovery()) + require.True(t, proto.Equal(&basev0.RunnableServiceOperation{ + Module: "releases", + Name: "backend", + Endpoint: "http", + // No component describes a query: the operation publishes it. + Operation: "GET /history", + InputMessage: "Releases_Get_History", + OutputMessage: "History", + Adaptation: basev0.RunnableServiceOperation_ADAPTATION_BOUNDED_JSON_V1, + }, pkg.GetServiceOperations()[0]), "service operation %v", pkg.GetServiceOperations()[0]) + require.Equal(t, runnablev0.ToolExposure_EFFECT_READ_ONLY, spec.Tool.GetEffect()) + + // A GET with no query takes the empty object. + recorded, _, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "releases.swagger.json", nil), releasesLocation("backend-http-recorded"), releasesOwner(), "GET", "/recorded") + require.NoError(t, err) + require.Empty(t, recorded.GetContract().GetInput().GetFields()) + require.Equal(t, "Board", recorded.GetServiceOperations()[0].GetOutputMessage()) +} + +// TestAGetIsAdmittedOnlyAsARead holds each half of a GET's operation to being +// a read, and refuses a GET whose input is not one flat query. +func TestAGetIsAdmittedOnlyAsARead(t *testing.T) { + marker := func(doc map[string]any) map[string]any { + return releasesOperation(doc, "/history")[runnable.OpenAPIOperationMarker].(map[string]any) + } + for _, test := range []struct { + name string + edit func(map[string]any) + because string + }{ + {name: "resolved by a receipt", edit: func(doc map[string]any) { + declared := marker(doc) + declared["recovery"], declared["max_attempts"], declared["retryable_codes"] = "RECOVERY_RECEIPT", 1, []any{} + declared["tool"].(map[string]any)["effect"] = "EFFECT_MUTATION" + }, because: "is a GET, which is admitted only as a read"}, + {name: "invoked holding a write", edit: func(doc map[string]any) { + marker(doc)["invoke_scopes"] = []any{map[string]any{"resource_kind": "records.records", "actions": []any{"read", "write"}}} + }, because: "is a GET, which is admitted only as a read"}, + {name: "a body", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{ + map[string]any{"name": "body", "in": "body", "schema": map[string]any{"$ref": "#/definitions/History"}}, + } + }, because: "declares a request body, and a GET's input is its query"}, + {name: "a path parameter", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{map[string]any{"name": "repo", "in": "path", "type": "string"}} + }, because: `"path" parameter "repo"`}, + {name: "a list in the query", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{ + map[string]any{"name": "repo", "in": "query", "schema": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, + } + }, because: `query parameter "repo" is ARRAY`}, + {name: "an object in the query", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{ + map[string]any{"name": "filter", "in": "query", "schema": map[string]any{"type": "object"}}, + } + }, because: `query parameter "filter" is JSON`}, + {name: "no operationId", edit: func(doc map[string]any) { + delete(releasesOperation(doc, "/history"), "operationId") + }, because: "declares no operationId"}, + {name: "an untyped query parameter", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{map[string]any{"name": "repo", "in": "query"}} + }, because: `query parameter "repo" declares no schema`}, + {name: "a query parameter twice", edit: func(doc map[string]any) { + releasesOperation(doc, "/history")["parameters"] = []any{ + map[string]any{"name": "repo", "in": "query", "type": "string"}, + map[string]any{"name": "repo", "in": "query", "type": "string"}, + } + }, because: `declares query parameter "repo" twice`}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "releases.swagger.json", test.edit), releasesLocation("backend-http-history"), releasesOwner(), "GET", "/history") + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, test.because) + }) + } + + // A query is a GET's input alone: a POST still carries its input as a body. + _, _, err := runnable.PackageFromOpenAPIOperation( + ingestionDocument(t, func(doc map[string]any) { + applyTextOperation(doc)["parameters"] = []any{map[string]any{"name": "tenant", "in": "query", "schema": map[string]any{"type": "string"}}} + }), ingestLocation(), ingestRestOwner(), "POST", ingestPath) + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, `"query" parameter "tenant"`) +} + +// TestDefaultRouteOperationIsTheMarkerAnOwnerPublishes covers the REST twin of +// DefaultOperation: what an owner that declares only a route's authority +// policy writes as its marker. A read is core's default read, presented by the +// route's summary; written as the marker and read back, it derives the +// operation the fixture declares by hand. +func TestDefaultRouteOperationIsTheMarkerAnOwnerPublishes(t *testing.T) { + reads := &runnable.Authority{Requires: []*basev0.WorkScopeV1{{ResourceKind: "records.records", Actions: []string{"read"}}}} + spec, err := runnable.DefaultRouteOperation("GET", "/history", reads, "releases", "Releases_Get_History", + "Read the recorded tags of one repository and the recorded pins of one component") + require.NoError(t, err) + require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, spec.Recovery) + require.Equal(t, uint32(runnable.DefaultReadAttempts), spec.MaxAttempts) + require.Equal(t, []string{"503"}, spec.RetryableCodes) + require.Equal(t, 30*time.Second, spec.AttemptTimeout) + require.True(t, proto.Equal(&runnablev0.ToolExposure{ + Name: "Releases_Get_History", + Description: "Read the recorded tags of one repository and the recorded pins of one component", + Effect: runnablev0.ToolExposure_EFFECT_READ_ONLY, + }, spec.Tool)) + + // RouteMarker is that operation as the marker an owner writes, from the + // policy JSON the owner already publishes beside it. + written, err := runnable.RouteMarker("GET", "/history", + json.RawMessage(`{"requires":[{"resource_kind":"records.records","actions":["read"]}]}`), "releases", "Releases_Get_History", + "Read the recorded tags of one repository and the recorded pins of one component") + require.NoError(t, err) + again, err := runnable.RouteMarker("GET", "/history", + json.RawMessage(`{"requires":[{"resource_kind":"records.records","actions":["read"]}]}`), "releases", "Releases_Get_History", + "Read the recorded tags of one repository and the recorded pins of one component") + require.NoError(t, err) + require.Equal(t, string(written), string(again), "a committed marker is written byte-stably") + _, err = runnable.RouteMarker("GET", "/history", json.RawMessage(`{"require":[]}`), "releases", "", "") + require.ErrorIs(t, err, runnable.ErrInvalid) + _, err = runnable.RouteMarker("GET", "/status", json.RawMessage(`{"any_checked_caller":true}`), "releases", "", "") + require.ErrorIs(t, err, runnable.ErrNotDerivable) + fromDefault, _, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "releases.swagger.json", func(doc map[string]any) { + var marker map[string]any + require.NoError(t, json.Unmarshal(written, &marker)) + releasesOperation(doc, "/history")[runnable.OpenAPIOperationMarker] = marker + }), releasesLocation("backend-http-history"), releasesOwner(), "GET", "/history") + require.NoError(t, err) + byHand, _, err := runnable.PackageFromOpenAPIOperation( + swaggerDocument(t, "releases.swagger.json", nil), releasesLocation("backend-http-history"), releasesOwner(), "GET", "/history") + require.NoError(t, err) + require.Equal(t, byHand.GetDigest(), fromDefault.GetDigest()) + + // Anything else is a mutation resolved by its receipt, over POST or PUT. + writes := &runnable.Authority{Requires: []*basev0.WorkScopeV1{{ResourceKind: "records.records", Actions: []string{"read", "write"}}}} + mutation, err := runnable.DefaultRouteOperation("POST", "/record", writes, "releases", "Releases_Post_Record", "") + require.NoError(t, err) + require.Equal(t, basev0.RunnableExecution_RECOVERY_RECEIPT, mutation.Recovery) + require.Equal(t, uint32(runnable.DefaultMutationAttempts), mutation.MaxAttempts) + require.Equal(t, runnablev0.ToolExposure_EFFECT_MUTATION, mutation.Tool.GetEffect()) + require.Equal(t, "Calls POST /record.", mutation.Tool.GetDescription()) + + // Neither a mutation over GET nor a policy requiring nothing is derived. + for name, refused := range map[string]func() error{ + "mutation over GET": func() error { + _, err := runnable.DefaultRouteOperation("GET", "/record", writes, "releases", "", "") + return err + }, + "path template": func() error { + _, err := runnable.DefaultRouteOperation("GET", "/modules/records/{path=**}", reads, "releases", "", "") + return err + }, + "any checked caller": func() error { + _, err := runnable.DefaultRouteOperation("GET", "/status", &runnable.Authority{AnyCheckedCaller: true}, "releases", "", "") + return err + }, + "no policy": func() error { + _, err := runnable.DefaultRouteOperation("POST", "/record", nil, "releases", "", "") + return err + }, + } { + t.Run(name, func(t *testing.T) { + require.ErrorIs(t, refused(), runnable.ErrNotDerivable) + }) + } +} + +// TestAPreparedGetIsHeldToBeingARead: the schema now admits GET, so an +// installer holds a delivered GET binding to the rule the derivation applies, +// rather than trust that a generator did. +func TestAPreparedGetIsHeldToBeingARead(t *testing.T) { + read := restBinding() + read.Operation.Spelling = "GET /v1/items" + read.Call.Route = &runnablev0.PreparedCall_Rest{Rest: &runnablev0.HTTPRoute{Verb: "GET", Path: "/v1/items"}} + read.Policy.Recovery = basev0.RunnableExecution_RECOVERY_RECOMPUTE + read.Policy.InvokeScopes = []*basev0.WorkScopeV1{{ResourceKind: "acme.item", Actions: []string{"read"}}} + read.Policy.LookupScopes = nil + _, err := runnable.DecodePrepared(encoded(t, read)) + require.NoError(t, err) + + mutation := proto.CloneOf(read) + mutation.Policy.Recovery = basev0.RunnableExecution_RECOVERY_RECEIPT + mutation.Policy.InvokeScopes = []*basev0.WorkScopeV1{{ResourceKind: "acme.item", Actions: []string{"read", "write"}}} + _, err = runnable.EncodePrepared(mutation) + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, "is a GET, which is admitted only as a read") + + deleted := proto.CloneOf(read) + deleted.Call.Route = &runnablev0.PreparedCall_Rest{Rest: &runnablev0.HTTPRoute{Verb: "DELETE", Path: "/v1/items"}} + deleted.Operation.Spelling = "DELETE /v1/items" + _, err = runnable.EncodePrepared(deleted) + require.ErrorIs(t, err, runnable.ErrInvalid) +} diff --git a/runnable/operation_openapi_test.go b/runnable/operation_openapi_test.go index 4e8390fe..f3e5d588 100644 --- a/runnable/operation_openapi_test.go +++ b/runnable/operation_openapi_test.go @@ -171,15 +171,21 @@ func TestPackageFromOpenAPIOperationReadsParameterLocationsAsAnAllowList(t *test } } -// TestParseOpenAPIDocumentRefusesSwaggerAsItself covers the document core's own -// OpenAPICombinator still writes. Swagger 2.0 puts a request body in an -// `in: body` parameter and a response schema on the response itself, so reading -// one as OpenAPI 3 finds no request body and says so — pointing at the wrong -// part of a document that plainly declares one. -func TestParseOpenAPIDocumentRefusesSwaggerAsItself(t *testing.T) { +// TestParseOpenAPIDocumentReadsEachDialectAsItself covers the two versions a +// document is derived from. Swagger 2.0 — what core's own OpenAPICombinator +// and solution-runtime-go write — is read as itself, and any other Swagger +// version is refused by name rather than read as an OpenAPI 3 document that +// declares nothing. +func TestParseOpenAPIDocumentReadsEachDialectAsItself(t *testing.T) { _, err := runnable.ParseOpenAPIDocument([]byte(`{"swagger":"2.0","paths":{},"definitions":{}}`)) - require.ErrorIs(t, err, runnable.ErrInvalid) - require.ErrorContains(t, err, `swagger "2.0"`) + require.NoError(t, err) + for _, declared := range []string{`"swagger":"1.2"`, `"swagger":"3.0.3"`, `"swagger":"2.0","openapi":"3.0.3"`} { + t.Run(declared, func(t *testing.T) { + _, err = runnable.ParseOpenAPIDocument([]byte(`{` + declared + `,"paths":{}}`)) + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, "swagger") + }) + } for _, declared := range []string{`"openapi":"2.0"`, `"openapi":"4.0.0"`, `"openapi":""`} { t.Run(declared, func(t *testing.T) { @@ -243,9 +249,10 @@ func TestPackageFromOpenAPIOperationRefusesWhatABoundedContractCannotDescribe(t edit func(map[string]any) because string }{ - {name: "get", method: "GET", because: "is not POST or PUT"}, - {name: "delete", method: "DELETE", because: "is not POST or PUT"}, - {name: "patch", method: "PATCH", because: "is not POST or PUT"}, + {name: "unpublished get", method: "GET", because: "publishes no GET"}, + {name: "delete", method: "DELETE", because: "is not GET, POST or PUT"}, + {name: "patch", method: "PATCH", because: "is not GET, POST or PUT"}, + {name: "path template", path: "/ingest/{kind}", because: "is a path template"}, {name: "unpublished path", path: "/ingest/blob", because: "publishes no path"}, {name: "unpublished method", edit: func(doc map[string]any) { delete(pathItem(doc), "post") diff --git a/runnable/prepared.go b/runnable/prepared.go index 6058f2bb..5ef74786 100644 --- a/runnable/prepared.go +++ b/runnable/prepared.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/http" "strings" "google.golang.org/protobuf/encoding/protojson" @@ -123,6 +124,13 @@ func VerifyPrepared(binding *runnablev0.PreparedBinding) error { if err := policy.Validate(); err != nil { return err } + // A GET is derived only as a read, and an installer holds a delivered one + // to the same rule rather than trust that a generator applied it. + if binding.GetCall().GetRest().GetVerb() == http.MethodGet { + if err := readOnlyRoute(policy, binding.GetOperation().GetSpelling()); err != nil { + return err + } + } return refuseUnresolvedSlots("prepared binding", policy) } diff --git a/runnable/testdata/openapi/ingestion.swagger.json b/runnable/testdata/openapi/ingestion.swagger.json new file mode 100644 index 00000000..4d8eb1c2 --- /dev/null +++ b/runnable/testdata/openapi/ingestion.swagger.json @@ -0,0 +1,142 @@ +{ + "swagger": "2.0", + "info": { + "title": "documents/runtime-worker", + "version": "0.1.0" + }, + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/ingest/text": { + "post": { + "operationId": "ApplyText", + "summary": "Apply a text document to the store", + "x-codefly-operation": { + "attempt_timeout": "10s", + "total_timeout": "60s", + "max_attempts": 1, + "backoff": "1s", + "retryable_codes": [ + "503" + ], + "audience": "documents.ingestion", + "invoke_scopes": [ + { + "resource_kind": "documents", + "actions": [ + "ingest", + "read" + ] + } + ], + "completion": "COMPLETION_CALL", + "lookup_scopes": [ + { + "resource_kind": "documents", + "actions": [ + "read" + ] + } + ] + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ApplyTextRequest" + } + } + ], + "responses": { + "200": { + "description": "the applied document", + "schema": { + "$ref": "#/definitions/ApplyTextResponse" + } + }, + "401": { + "description": "no Work Context", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + } + }, + "definitions": { + "ApplyTextRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "origin": { + "type": "string" + }, + "container": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "path": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "cursor": { + "type": "string", + "format": "int64" + }, + "contentType": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ApplyTextResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "reference": { + "type": "string" + }, + "contentHash": { + "type": "string" + }, + "effectDigest": { + "type": "string" + }, + "minted": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "quarantined": { + "type": "boolean" + } + } + }, + "Error": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + } + } + } +} diff --git a/runnable/testdata/openapi/releases.swagger.json b/runnable/testdata/openapi/releases.swagger.json new file mode 100644 index 00000000..428ca89f --- /dev/null +++ b/runnable/testdata/openapi/releases.swagger.json @@ -0,0 +1,101 @@ +{ + "swagger": "2.0", + "info": { + "title": "releases solution backend", + "version": "0.3.0" + }, + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/recorded": { + "get": { + "operationId": "Releases_Get_Recorded", + "summary": "Read the board as records holds it, without asking GitHub", + "x-codefly-authority-policy": {"requires": [{"resource_kind": "records.records", "actions": ["read"]}]}, + "x-codefly-operation": { + "attempt_timeout": "30s", + "total_timeout": "90s", + "max_attempts": 3, + "backoff": "1s", + "retryable_codes": ["503"], + "audience": "releases", + "invoke_scopes": [{"resource_kind": "records.records", "actions": ["read"]}], + "completion": "COMPLETION_CALL", + "recovery": "RECOVERY_RECOMPUTE", + "tool": { + "name": "Releases_Get_Recorded", + "description": "Read the board as records holds it, without asking GitHub", + "effect": "EFFECT_READ_ONLY" + } + }, + "responses": { + "200": {"description": "A successful response.", "schema": {"$ref": "#/definitions/Board"}}, + "401": {"description": "No Work Context.", "schema": {"$ref": "#/definitions/Error"}} + } + } + }, + "/history": { + "get": { + "operationId": "Releases_Get_History", + "summary": "Read the recorded tags of one repository and the recorded pins of one component", + "x-codefly-authority-policy": {"requires": [{"resource_kind": "records.records", "actions": ["read"]}]}, + "x-codefly-operation": { + "attempt_timeout": "30s", + "total_timeout": "90s", + "max_attempts": 3, + "backoff": "1s", + "retryable_codes": ["503"], + "audience": "releases", + "invoke_scopes": [{"resource_kind": "records.records", "actions": ["read"]}], + "completion": "COMPLETION_CALL", + "recovery": "RECOVERY_RECOMPUTE", + "tool": { + "name": "Releases_Get_History", + "description": "Read the recorded tags of one repository and the recorded pins of one component", + "effect": "EFFECT_READ_ONLY" + } + }, + "parameters": [ + {"name": "repo", "in": "query", "required": true, "type": "string"}, + {"name": "component", "in": "query", "required": false, "type": "string"} + ], + "responses": { + "200": {"description": "A successful response.", "schema": {"$ref": "#/definitions/History"}}, + "401": {"description": "No Work Context.", "schema": {"$ref": "#/definitions/Error"}} + } + } + } + }, + "definitions": { + "Board": { + "type": "object", + "additionalProperties": false, + "properties": { + "observedAt": {"type": "string"}, + "components": {"type": "array", "items": {"$ref": "#/definitions/Component"}} + } + }, + "Component": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "pinned": {"type": "string"}, + "latest": {"type": "string"} + } + }, + "History": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}} + } + }, + "Error": { + "type": "object", + "additionalProperties": false, + "properties": {"error": {"type": "string"}} + } + } +}