diff --git a/README.md b/README.md index 06a54c5..1881832 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,24 @@ Generates Dyalog APL client code from OpenAPI 3.0 specifications. -[![Release](https://github.com/Dyalog/OpenAPI/actions/workflows/release.yml/badge.svg)](https://github.com/Dyalog/OpenAPI/releases/latest) +[![Release](https://github.com/dyalog-labs/OpenAPI/actions/workflows/release.yml/badge.svg)](https://github.com/dyalog-labs/OpenAPI/releases/latest) > **This project is in active development and may not yet be fully functional with the entirety of the OpenAPI specification. Please raise a GitHub issue for any discovered bugs or feature requests.** ## Documentation -Full documentation is available at **[dyalog.github.io/OpenAPI](https://dyalog.github.io/OpenAPI)**, including installation, CLI reference, and a guide to the generated client. +Full documentation is available at **[dyalog-labs.github.io/OpenAPI](https://dyalog-labs.github.io/OpenAPI)**, including installation, CLI reference, and a guide to the generated client. ## Quick Start -1. Download the binary for your platform from the [GitHub Releases page](https://github.com/Dyalog/OpenAPI/releases/latest). +1. Download the binary for your platform from the [GitHub Releases page](https://github.com/dyalog-labs/OpenAPI/releases/latest). 2. Run the generator against your spec: ``` openapidyalog path/to/openapi.yaml ./output ``` -See [Installation](https://dyalog.github.io/OpenAPI/installation/) for platform-specific setup details. +See [Installation](https://dyalog-labs.github.io/OpenAPI/installation/) for platform-specific setup details. ## Requirements diff --git a/dev-docs/api-reference.md b/dev-docs/api-reference.md index 1b43962..9c708de 100644 --- a/dev-docs/api-reference.md +++ b/dev-docs/api-reference.md @@ -52,29 +52,32 @@ Task GenerateVersionAsync(OpenApiDocument document, string outputDirectory) Task CopyHttpCommandAsync(string outputDirectory) Task CopySpecificationAsync(string specificationPath, string outputDirectory) Task GenerateClientAsync(OpenApiDocument document, string outputDirectory) -Task GenerateReadmeAsync(OpenApiDocument document, string outputDirectory) +Task GenerateReadmeAsync(OpenApiDocument document, string outputDirectory, IReadOnlyDictionary? inlineSchemas = null) +Task GenerateTagDocsAsync(OpenApiDocument document, string outputDirectory, IReadOnlyDictionary? inlineSchemas = null) ``` +`GenerateTagDocsAsync` writes `docs/.md` for each tag, named by the tag's APL name. + ## `EndpointGeneratorService` Generates one `.aplf` file per operation. ```csharp -Task> GenerateEndpointsAsync( +Task> GenerateEndpointsAsync( OpenApiDocument document, string outputDirectory, - string? @namespace) + string? @namespace = null) ``` -Returns a list of inline schemas discovered during generation (reserved for future model generation). +Returns the inline request body schemas found during generation, keyed by the name of the model to generate for each. ## `ModelGeneratorService` -Placeholder — not yet implemented. Both methods are intentional no-ops. +Generates one model class per schema, in `APLSource/models/`. ```csharp Task GenerateComponentModelsAsync(OpenApiDocument document, string outputDirectory) -Task GenerateInlineSchemaModelsAsync(List<(string Name, OpenApiSchema Schema)> schemas, string outputDirectory) +Task GenerateInlineSchemaModelsAsync(IReadOnlyDictionary inlineSchemas, OpenApiDocument document, string outputDirectory) ``` ## `TemplateService` @@ -109,7 +112,7 @@ CLI configuration passed through the pipeline. static string ToValidAplName(string name) ``` -Converts an arbitrary string to a valid APL identifier. Invalid characters are replaced with `⍙⍙` escaping, identical to Dyalog's JSON name mangling (`7159⌶`). +Converts an arbitrary string to a valid APL identifier. Invalid characters are replaced with `⍙⍙` escaping, identical to Dyalog's JSON name mangling (`0(7162⌶)`). ```csharp static string CommentLines(string? text) @@ -123,4 +126,44 @@ Prefixes each line of `text` with `⍝ `. static string ToDyalogPath(string path) ``` -Converts an OpenAPI path template (e.g. `/pets/{petId}`) to a Dyalog APL expression (e.g. `'/pets/',(⍕argsNs.petId)`). +Converts an OpenAPI path template (e.g. `/pets/{petId}`) to a Dyalog APL expression (e.g. `'/pets/',(c.∆.HttpCommand.UrlEncode⍕argsNs.petId)`). + +## `OperationNaming` + +Naming rules shared by code and docs generation. + +```csharp +static string TagOf(OpenApiOperation operation) +static string TagName(string tag) +static string FunctionName(string? operationId, string method, string path) +static List MergeParameters(IOpenApiPathItem? pathItem, OpenApiOperation operation) +static JsonBody? DescribeJsonBody(IOpenApiSchema schema, string functionName, OpenApiDocument? document) +``` + +`TagName` gives the Client field, `_tags` directory and docs file name for a tag; it is always a valid APL name, so is safe as a file name. `MergeParameters` overrides path item parameters with operation parameters of the same name and location. `DescribeJsonBody` gives the argument name, model name and array-ness of a JSON request body. A `$ref` is resolved, so a reference to an array of models is an array; a reference to anything else has no model. For an unnamed body that is not a model or an array of models, it returns null (the body is then passed as `body`). + +## `SchemaHelpers` + +Schema rules shared by model, endpoint and docs generation. + +```csharp +static IOpenApiSchema? Resolve(IOpenApiSchema? schema, OpenApiDocument? document) +static bool IsObjectModel(IOpenApiSchema schema) +static bool IsModelReference(IOpenApiSchema? schema, OpenApiDocument? document) +static string ClassNameOf(string schemaName) +static IEnumerable> ModelComponents(OpenApiDocument document) +static List<(string Key, IOpenApiSchema Schema, bool Required)> Properties(IOpenApiSchema schema, OpenApiDocument? document) +static void CheckModelClassNames(OpenApiDocument document) +``` + +Only object schemas (`ModelComponents`) get a model class. `Properties` includes properties inherited through `allOf` at any depth. `CheckModelClassNames` throws if two schema names map to the same class. + +## `DocsBuilder` + +```csharp +DocsBuilder(OpenApiDocument document, IReadOnlyDictionary? inlineSchemas = null) +List BuildTags() +List BuildModels() +``` + +Builds the documentation model for `README.md` and `docs/.md`, including a usage example for each operation. `inlineSchemas` is what `EndpointGeneratorService.GenerateEndpointsAsync` returns, so the docs name each inline model as it was generated (an inline model is numbered if its name is taken). diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 11dab4b..bcd8002 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -12,15 +12,15 @@ OpenAPI spec (JSON/YAML) │ ▼ CodeGeneratorService — orchestrates generation; delegates to three sub-services: - ├── ArtifactGeneratorService — utils.apln, Version.aplf, HttpCommand.aplc, Client.aplc, README.md + ├── ArtifactGeneratorService — utils.apln, Version.aplf, HttpCommand.aplc, Client.aplc, README.md, docs/.md ├── EndpointGeneratorService — one .aplf per operation, grouped under _tags// - └── ModelGeneratorService — model classes (not yet implemented) + └── ModelGeneratorService — one .aplc model class per schema, under models/ │ ▼ TemplateService — renders each Scriban template with its context object │ ▼ - Output directory — APLSource/ tree + README.md + Output directory — APLSource/ tree + README.md + docs/ ``` ## Services @@ -38,8 +38,9 @@ Wraps `Microsoft.OpenApi.Reader` to parse and optionally validate the spec. Retu Thin orchestrator. Calls the three sub-services in order: 1. `ArtifactGeneratorService` — shared/static artifacts -2. `EndpointGeneratorService` — per-operation function files -3. `ModelGeneratorService` — model classes (currently no-ops) +2. `EndpointGeneratorService` — per-operation function files, returning the inline request body schemas it found +3. `ArtifactGeneratorService` — `Client.aplc`, `README.md` and `docs/.md` +4. `ModelGeneratorService` — model classes for the inline schemas and the component schemas ### `ArtifactGeneratorService` @@ -52,15 +53,18 @@ Generates files that are the same regardless of the number of operations: | `APLSource/HttpCommand.aplc` | Embedded binary (copied directly) | | `APLSource/Client.aplc` | `Client.aplc.scriban` | | `README.md` | `README.md.scriban` | +| `docs/.md` (one per tag) | `docs/tag.md.scriban` | The original spec file is also copied into the output directory. +The README and tag pages are built from `DocsBuilder`, which produces a usage example for each operation. Their names come from `OperationNaming`, as the endpoint functions' names do, so the docs always match the generated code. + ### `EndpointGeneratorService` Groups all operations by tag, then for each operation: -1. Constructs an `OperationTemplateContext` from the OpenAPI operation object -2. Resolves the request body type and form fields +1. Constructs an `OperationTemplateContext` from the OpenAPI operation object, with the path item's parameters merged in +2. Resolves the request body's argument name, model and form fields 3. Converts path parameter placeholders into Dyalog APL concatenation expressions via `PathConverter` 4. Renders `endpoint.aplf.scriban` and writes to `APLSource/_tags//.aplf` @@ -68,30 +72,42 @@ Operations with no tag are placed under the `default` tag. ### `ModelGeneratorService` -Placeholder — both public methods are intentional no-ops. Model generation is planned but not yet implemented. +Renders `models/model.aplc.scriban` once per component object schema, and once per inline request body schema found by `EndpointGeneratorService` (named `Request`). Array and primitive schemas get no class; a `$ref` to one is treated as that schema. `allOf` sub-schemas are flattened into one class, to any depth; an object schema with no properties of its own becomes a map type. A model keeps its property values in a private namespace, `⍙v`, by JSON name, so a property cannot clash with the class's own members; a property whose name is one the template uses (`FormatNS`, `args`, an `Enum…` namespace, …) has `_` appended. + +`CodeGeneratorService` first checks that no two schema names map to the same class name (e.g. `pet` and `Pet`), and stops with an error if they do, as one class would overwrite the other. ### `TemplateService` Loads Scriban templates from embedded assembly resources and renders them with a context object. When building the Scriban script object, all C# property names are converted to `snake_case` so templates use `operation_id` rather than `OperationId`. `CustomProperties` dictionaries on context objects are merged in the same way. -Two custom template functions are registered: +These custom template functions are registered: - `comment_lines` — prefixes each line of a string with `⍝` -- `get_operations_by_tag` — returns operations grouped by tag, for use in the README template +- `apl_name` — converts a name to a valid APL identifier (`StringHelpers.ToValidAplName`) +- `one_line` — collapses a string onto one line +- `md_cell` — makes a string safe for a Markdown table cell ## Context objects Each template receives a typed context object. The two main ones are: -**`ApiTemplateContext`** — used for document-level templates (Client, Utils, README, etc.). Exposes the full OpenAPI document, API title/version/description, base URL, all tags, and security scheme metadata. +**`ApiTemplateContext`** — used for document-level templates (Client, Utils, README, etc.). Exposes the full OpenAPI document, API title/version/description, base URL, all tags, and security scheme metadata, plus `TagDocs` and `Models` (built by `DocsBuilder`) for the README and tag pages. + +**`ModelTemplateContext`** — used for model templates. Exposes a model's class name, properties and enum values. **`OperationTemplateContext`** — used for endpoint templates. Exposes a single operation's method, path, parameters, request body (including content type and resolved field names), response codes, and security requirements. ## Key utilities -**`PathConverter`** — converts an OpenAPI path template such as `/pets/{petId}` into a Dyalog APL expression: `'/pets/',(⍕argsNs.petId)`. Segments and parameter references are concatenated with `,`. +**`PathConverter`** — converts an OpenAPI path template such as `/pets/{petId}` into a Dyalog APL expression: `'/pets/',(c.∆.HttpCommand.UrlEncode⍕argsNs.petId)`. Segments and parameter references are concatenated with `,`. Each parameter value is percent-encoded with `HttpCommand.UrlEncode`, so a value containing `/` or a space stays in its own path segment. + +**`StringHelpers.ToValidAplName`** — converts an arbitrary string into a valid APL identifier. If the name contains characters outside the APL identifier character set, or begins with a digit, it is prefixed with `⍙` and each invalid character is replaced with `⍙⍙`. This is the same escaping scheme used by Dyalog's JSON name mangling (`0(7162⌶)`), so `⎕JSON` restores the original name. Parameters and model properties whose names are not valid APL are held under these names. + +**`OperationNaming`** — the naming rules shared by code and docs generation: a tag's APL name (its Client field, `_tags` directory and docs file name), an operation's function name, the merged parameter list, and the argument name and model for a JSON request body. + +**`SchemaHelpers`** — schema rules shared by model, endpoint and docs generation: resolving a `$ref`, whether a schema gets a model class, a schema's properties including those inherited through `allOf`, and the model class name for a schema. -**`StringHelpers.ToValidAplName`** — converts an arbitrary string into a valid APL identifier. If the name contains characters outside the APL identifier character set, or begins with a digit, it is prefixed with `⍙` and each invalid character is replaced with `⍙⍙`. This is the same escaping scheme used by Dyalog's JSON name mangling (`7159⌶`). +**`DocsBuilder`** — builds the documentation model for the README and tag pages, including a usage example for each operation. ## Design notes diff --git a/dev-docs/development-setup.md b/dev-docs/development-setup.md index 2c39d3a..191e0ba 100644 --- a/dev-docs/development-setup.md +++ b/dev-docs/development-setup.md @@ -9,7 +9,7 @@ ## Clone the repository ```bash -git clone https://github.com/Dyalog/OpenAPI.git +git clone https://github.com/dyalog-labs/OpenAPI.git cd OpenAPI ``` diff --git a/dev-docs/templates.md b/dev-docs/templates.md index 08e1011..d99f7e5 100644 --- a/dev-docs/templates.md +++ b/dev-docs/templates.md @@ -15,6 +15,10 @@ Templates/ │ ├── HttpCommand.aplc → APLSource/HttpCommand.aplc (copied as-is, not a template) │ ├── _tags/ │ │ └── endpoint.aplf.scriban → APLSource/_tags//.aplf +│ └── models/ +│ └── model.aplc.scriban → APLSource/models/.aplc +├── docs/ +│ └── tag.md.scriban → docs/.md └── README.md.scriban → README.md ``` @@ -60,7 +64,7 @@ C# context properties are automatically renamed to `snake_case` before being pas ### Document-level templates -Used by `Client.aplc.scriban`, `utils.apln.scriban`, `Version.aplf.scriban`, `README.md.scriban`. +Used by `Client.aplc.scriban`, `utils.apln.scriban`, `Version.aplf.scriban`, `README.md.scriban`, `docs/tag.md.scriban`. | Property | Description | |---|---| @@ -71,13 +75,18 @@ Used by `Client.aplc.scriban`, `utils.apln.scriban`, `Version.aplf.scriban`, `RE | `generated_at` | UTC timestamp of generation | | `tags` | List of all tag names used in the spec | | `security_schemes` | Map of security scheme name → scheme info (`type`, `in`, `parameter_name`, `scheme`, `bearer_format`) | -| `schemas` | Map of component schema name → schema info | +| `has_security_schemes` | Whether there are any security schemes (`.size` does not work on maps in Scriban) | +| `schemas` | Map of component schema name → schema | +| `tag_docs` | List of tags, each with `name`, `apl_name`, `display_name`, `description`, `doc_path` and `operations`. Each operation has `function_name`, `method`, `path`, `summary`, `description`, `deprecated`, `parameters`, `request_body`, `responses` and `usage_example` | +| `models` | List of generated models, each with `class_name` and `description` | Custom properties (set per-template in `ArtifactGeneratorService`): | Property | Set for | Description | |---|---|---| -| `class_name` | `Client.aplc` | Generated class name (default: `Client`) | +| `class_name` | `Client.aplc`, `README.md` | Generated class name (default: `Client`) | +| `tags` | `Client.aplc` | APL names of the tags, one per Client field | +| `tag` | `docs/tag.md` | The tag being documented (an item of `tag_docs`) | ### Endpoint template @@ -88,13 +97,15 @@ Used by `_tags/endpoint.aplf.scriban`. | `operation_id` | Sanitised, PascalCase function name | | `method` | HTTP method (lowercase) | | `path` | Original path string (e.g. `/pets/{petId}`) | -| `dyalog_path` | Path converted to Dyalog APL expression (e.g. `'/pets/',(⍕argsNs.petId)`) | +| `dyalog_path` | Path converted to Dyalog APL expression (e.g. `'/pets/',(c.∆.HttpCommand.UrlEncode⍕argsNs.petId)`) | | `summary` | Operation summary | | `description` | Operation description | | `parameters` | List of parameters, each with `name`, `in`, `required`, `schema` | | `request_body` | Request body object, or null | | `request_content_type` | Content type of the request body | -| `request_json_body_type` | Resolved JSON schema type name, or null | +| `request_json_body_type` | Model class for a JSON body (or its items), or null | +| `request_body_arg_name` | Field on `argsNs` that holds a JSON body | +| `request_body_is_array` | Whether a JSON body is an array | | `form_fields` | List of form fields for `multipart/form-data` | | `responses` | Map of status code → response | | `has_security` | Boolean — whether the operation declares security requirements | @@ -102,7 +113,7 @@ Used by `_tags/endpoint.aplf.scriban`. ## Custom template functions -Two functions are available in all templates: +These functions are available in all templates: **`comment_lines `** — prefixes every line of `text` with `⍝ `. @@ -110,7 +121,11 @@ Two functions are available in all templates: {{ comment_lines description }} ``` -**`get_operations_by_tag`** — returns operations grouped by tag, used in `README.md.scriban`. Returns a dictionary of tag name → list of operation info objects (each with `operation_id`, `method`, `path`, `summary`, `parameters`, `has_request_body`). +**`apl_name `** — converts `name` to a valid APL identifier, as used for parameters on `argsNs`. + +**`one_line `** — collapses `text` onto one line. + +**`md_cell `** — makes `text` safe for a Markdown table cell: one line, with `|` escaped. ## Modifying a template diff --git a/docs/_archive/examples.md b/docs/_archive/examples.md index 957ceaf..6b7df65 100644 --- a/docs/_archive/examples.md +++ b/docs/_archive/examples.md @@ -18,7 +18,7 @@ dotnet run -- ../pet-store/openapi.json ../pet-store/PetStore-Client ]LINK.Import # pet-store/PetStore-Client/APLSource ⍝ Create a client instance -client←⎕NEW Client (baseURL:'https://petstore3.swagger.io/api/v3') +client←⎕NEW Client (baseUrl:'https://petstore3.swagger.io/api/v3') ⍝ Find available pets pets←api.pet.findPetsByStatus.sync (client:client ⋄ status:'available') diff --git a/docs/_archive/usage-guide.md b/docs/_archive/usage-guide.md index 26dbf80..eaa91a9 100644 --- a/docs/_archive/usage-guide.md +++ b/docs/_archive/usage-guide.md @@ -31,7 +31,7 @@ This will create a complete APL client in the `./output` directory. ```apl ⍝ Create a client instance -client ← ⎕NEW Client (baseURL: 'https://api.example.com/v4') +client ← ⎕NEW Client (baseUrl: 'https://api.example.com/v4') ⍝ Call an API method result ← api.pet.findPetsByStatus.syncDetailed (client:client ⋄ status:'available') diff --git a/docs/generated-client/client.md b/docs/generated-client/client.md index 0e6923a..44e4126 100644 --- a/docs/generated-client/client.md +++ b/docs/generated-client/client.md @@ -20,14 +20,21 @@ client ← ⎕NEW Client config |-------------|---|---|---| | `baseUrl` | Yes | | Root URL prepended to every request path. | | `mock` | No | `¯1` | Controls whether requests are actually sent. See [Mock mode](#mock-mode). | -| `headers` | No | `⍬` | Extra headers sent with every request, as a vector of name–value pairs. | +| `headers` | No | `⍬` | Extra headers sent with every request: a vector of name–value pairs, such as `('X-One' 'a')('X-Two' 'b')`, or a single pair, or a 2-column matrix. | | `security` | No[^1] | | Authentication credentials. See [Authentication](#authentication). | [^1]: Required if the API uses authentication. ## Authentication -API key, HTTP bearer, and HTTP basic authentication are supported. OAuth is not currently implemented, but planned to be implemented. +API key, HTTP bearer, and HTTP basic authentication are supported. OAuth is not currently implemented, but planned to be implemented. An operation that accepts only schemes the client does not support, such as OAuth, is sent without credentials, so you can supply them yourself in `config.headers`: + +```apl +config ← ( + baseUrl: 'https://api.example.com' + headers: ,⊂'Authorization' ('Bearer ',token) +) +``` Set a `security` namespace on the config with the fields required by the API's authentication scheme. Only provide the fields relevant to the scheme used. diff --git a/docs/generated-client/index.md b/docs/generated-client/index.md index 1847c05..a6d6c32 100644 --- a/docs/generated-client/index.md +++ b/docs/generated-client/index.md @@ -13,22 +13,28 @@ Running the generator writes the following to your output directory: │ ├── HttpCommand.aplc ⍝ Bundled HTTPCommand library │ ├── utils.apln ⍝ Internal utility functions │ ├── Version.aplf ⍝ Version string function -│ ├── _tags/ +│ ├── _tags/ │ │ └── / ⍝ One sub-directory per API tag │ │ └── .aplf ⍝ One function per operation -│ └── models/ ⍝ Model classes (reserved; not yet generated) -└── README.md ⍝ Quick-start guide for this API +│ └── models/ +│ └── .aplc ⍝ One class per schema +├── docs/ +│ └── .md ⍝ Reference for each tag's operations +└── README.md ⍝ Quick start and an index of the API ``` The original spec file is also copied into the output directory alongside the `README.md`. +The `README.md` and the pages in `docs/` are written for the specific API: they show how to authenticate, and give a ready-to-run example of calling each operation with its parameters and request body. They are a good place to start when using a newly generated client. + ## Organising principle The structure mirrors the tag and operation layout of the OpenAPI spec: -- Each **tag** becomes a public field on the `Client` class, holding a namespace of that tag's operations. For example, a `user` tag would produce `client.user`. +- Each **tag** becomes a public field on the `Client` class, holding a namespace of that tag's operations. The field is named after the tag in camelCase: a `user` tag would produce `client.user`, and a `store orders` tag `client.storeOrders`. - Each **operation** within a tag becomes a function in that namespace, named after the `operationId`. For example, `listUsers` or `list_users` would produce `client.user.ListUsers`. - Operations that have no tag are grouped under a `default` namespace. +- Each object **schema** in the spec's `components` becomes a [model class](models.md) in the `models` namespace. This means you always call an endpoint as `client..`. diff --git a/docs/generated-client/models.md b/docs/generated-client/models.md new file mode 100644 index 0000000..2453d3c --- /dev/null +++ b/docs/generated-client/models.md @@ -0,0 +1,92 @@ +# Models + +Each object schema in the spec's `components` produces a class in `APLSource/models/`. A request body defined within an operation, rather than by reference to a schema, also produces a class, named after the operation: `Request`. + +``` +APLSource/models/.aplc +``` + +A schema that is not an object — an array or a string, say — produces no class. Wherever it is used, its value is used directly: a schema that is an array of `Pet`s is used as a vector of `models.Pet` instances (or namespaces). + +A schema that extends others with `allOf` has their properties too, to any depth. + +Models are optional. A request body can always be given as a plain namespace, and a response is always available as a namespace in `response.Data`. Models add checking of required fields and enum values when building a request body, and a typed object to read a response into. + +## Creating an instance + +Pass a namespace of property values to the constructor: + +```apl +pet ← ⎕NEW models.Pet (name: 'Rex' ⋄ status: 'available') +``` + +If a property that the schema marks as required is missing, the constructor signals an error: + +```apl + ⎕NEW models.Pet (status: 'available') +DOMAIN ERROR: Missing required fields: name +``` + +An instance can also be created empty, with `⎕NEW models.Pet`, and its properties set afterwards. + +## Properties + +Each property in the schema becomes a property of the class, with the same name. A name that is not valid APL is mangled in the same way as for [parameters](operations.md#passing-parameters), and `⎕JSON` restores the original name when the model is sent. In the rare case that a name is one the class uses itself, such as `FormatNS`, the property has `_` appended (`FormatNS_`); the comment at the top of the class file lists each property's name. + +When building an instance from a namespace, or reading one with `FromResponse`, the namespace uses the JSON names (mangled where needed), not any `_`-appended property names. + +A property that has not been set reads as `⊂'null'`. A property the schema marks `readOnly` is set by the server: it can be read from a model built with [`FromResponse`](#fromresponse), but cannot be set, and is not sent in a request. + +The comment at the top of each class file lists its properties, with their types and whether each is required. + +### Enums + +Setting a property that the schema restricts to a list of values to any other value signals an error: + +```apl + pet.status ← 'lost' +DOMAIN ERROR: status must be one of: available, pending, sold +``` + +The allowed values are also available as constants, in a namespace named `Enum`: + +```apl +pet.status ← models.Pet.EnumStatus.Sold ⍝ 'sold' +``` + +String values are character vectors, numeric values are numbers, and boolean values are `⊂'true'` and `⊂'false'`, as `⎕JSON` represents them. If the schema allows null, the property can also be set to `⊂'null'`. + +## Sending a model + +An operation that takes a JSON request body accepts a model instance in place of a namespace (or, for an array body, a vector of them): + +```apl +response ← client.pet.AddPet (pet: pet) +``` + +The instance is converted to a namespace with its `FormatNS` method. This can also be called directly, to see what will be sent: + +```apl + 1 ⎕JSON pet.FormatNS +{"name":"Rex","status":"available"} +``` + +`FormatNS` leaves out properties that have not been set and read-only properties. A property that holds another model, or a vector of them, is converted in turn; it can equally hold a plain namespace. + +## FromResponse + +`FromResponse` fills an instance from a namespace, such as a parsed JSON response, and returns the instance. Properties that hold other models are filled with instances of those models: + +```apl +response ← client.pet.GetPetById (petId: 1) +pet ← (⎕NEW models.Pet).FromResponse response.Data +pet.category.name ⍝ pet.category is a models.Category +``` + +## Map types + +A schema with no properties of its own describes an object with arbitrary keys, unless it sets `additionalProperties: false`. This includes a bare `{"type": "object"}`. Its class holds a namespace of those keys and values as given: + +```apl +labels ← ⎕NEW models.Labels (colour: 'red' ⋄ size: 'L') +``` diff --git a/docs/generated-client/operations.md b/docs/generated-client/operations.md index f9a47fc..ab37d98 100644 --- a/docs/generated-client/operations.md +++ b/docs/generated-client/operations.md @@ -18,7 +18,7 @@ The right argument `argsNs` is a namespace whose fields correspond to the operat ## Passing parameters -All parameters — path, query, header, and body — are passed as fields on the `argsNs` namespace: +All parameters — path, query, header, and body — are passed as fields on the `argsNs` namespace. This includes parameters the spec declares for every operation on a path, as well as those declared for the operation itself: ```apl response ← client.user.GetById (id: 42) @@ -30,13 +30,30 @@ For operations with no parameters, pass an empty namespace: response ← client.user.List () ``` +A parameter whose name is not a valid APL name is passed under its mangled name, formed in the same way as [Dyalog's JSON name mangling](https://docs.dyalog.com/20.0/language-reference-guide/primitive-operators/i-beam/json-translate-name/): the name is prefixed with `⍙`, and each invalid character is replaced with `⍙⍙`. The original name is used in the request. For example, an `X-Request-ID` header is passed as `⍙X⍙45⍙Request⍙45⍙ID`, and a `page[size]` query parameter as `⍙page⍙91⍙size⍙93⍙`: + +```apl +response ← client.user.List (⍙page⍙91⍙size⍙93⍙: 50) +``` + +The pages generated in `docs/` give the name to use for each parameter. + ### Path parameters -Path parameters are always required. The value must be a character vector or a scalar number — anything else signals an error. Numeric values are converted to strings automatically before being substituted into the URL. +Path parameters are always required. The value must be a character vector or a scalar number — anything else signals an error. Numbers are written as in JSON (`-0.5`, not `¯0.5`), and the value is percent-encoded, so a value containing `/`, `?` or a space stays within its own path segment. + +The path parameters are those named in the path, such as `{petId}` in `/pets/{petId}`. If the spec does not declare one, it is still required, as a string. ### Query and header parameters -These are read from `argsNs` by name. Required parameters signal an error if absent; optional parameters are simply omitted from the request if not set on the namespace. +These are read from `argsNs` by name. Required parameters signal an error if absent; optional parameters are simply omitted from the request if not set on the namespace. Numbers in query parameters are written as in JSON, as for path parameters. + +An array query parameter that the spec declares with `explode: false` is sent as one value, its items joined by the delimiter for its style: `,` for `form` (the default), a space for `spaceDelimited`, `|` for `pipeDelimited`. Pass a vector of strings or numbers, or a single string: + +```apl +response ← client.weather.GetForecast (latitude:'51.5' ⋄ longitude:'-0.12' ⋄ hourly:'temperature_2m' 'rain') +⍝ GET /v1/forecast?hourly=temperature_2m%2Crain&latitude=51.5&longitude=-0.12 +``` ### Request body @@ -44,11 +61,38 @@ How the body parameter is named on `argsNs` depends on the content type declared | Content type | Field name on `argsNs` | |---|---| -| `application/json` | Named after the schema type in camelCase (e.g. `user` for a `User` schema) | +| `application/json` | Named after the body's schema in camelCase — see below | | `application/octet-stream` | `body` | | `multipart/form-data` | One field per form field — see [Multipart form fields](#multipart-form-fields) below | | Other | `data` | +A JSON body's field depends on its schema: + +| Schema | Field name | Value | +|---|---|---| +| A named object schema, e.g. `User` | `user` | A namespace, or a `models.User` instance | +| An array of a named schema, e.g. of `User` | `user` | A vector of namespaces or `models.User` instances | +| A named schema that is an array, e.g. `UserList`, an array of `User` | `userList` | A vector of namespaces or `models.User` instances | +| A named schema that is anything else, e.g. `Note`, a string | `note` | Any value that `⎕JSON` can convert | +| An object defined in the operation itself, including a free-form one | `Request`, e.g. `createUserRequest` | A namespace, or an instance of the [model](models.md) generated for it | +| An array of objects defined in the operation itself | `RequestItem` | A vector of namespaces or instances of the model generated for them | +| Anything else defined in the operation itself (a string, an array of strings, …) | `body` | Any value that `⎕JSON` can convert | + +An optional request body that is not given is left out of the request altogether, with no `Content-Type` header. + +For example, for an operation that takes a `User`: + +```apl +⍝ A namespace… +response ← client.user.CreateUser (user: (name: 'Ada' ⋄ email: 'ada@example.com')) + +⍝ …or a model instance, which checks required fields and enum values as it is built +user ← ⎕NEW models.User (name: 'Ada' ⋄ email: 'ada@example.com') +response ← client.user.CreateUser (user: user) +``` + +The page generated in `docs/` for each tag shows the fields of each request body. + ### Multipart form fields For `multipart/form-data` operations, each form field in the spec becomes a field on `argsNs`. The value of each field follows the [HttpCommand multipart convention](https://dyalog.github.io/HttpCommand/latest/content-types/#special-treatment-of-content-type-multipartform-data): it is either a simple value (e.g. a character vector) or a 1–3 element vector of: @@ -57,7 +101,9 @@ For `multipart/form-data` operations, each form field in the spec becomes a fiel (content) (mime-type) (filename) ``` -`mime-type` and `filename` are optional. For file uploads, `content` may be a file path prefixed with: +`mime-type` and `filename` are optional. + +HttpCommand sends each form field under its name in the namespace, so a field whose name is not a valid APL name (such as `include[]`) cannot be sent; supplying it signals an error. For the same reason, a form field that is an array cannot be sent as repeated parts. For file uploads, `content` may be a file path prefixed with: - `@` — upload the file's content and include its original filename in the request - `<` — upload only the file's content, omitting the filename diff --git a/docs/generated-client/utils.md b/docs/generated-client/utils.md index 6858387..a9a9941 100644 --- a/docs/generated-client/utils.md +++ b/docs/generated-client/utils.md @@ -25,7 +25,23 @@ The scheme used is determined by the operation's declared security requirements 2. **Bearer token** — added as an `Authorization: Bearer …` header 3. **Basic auth** — credentials are base64-encoded and added as an `Authorization: Basic …` header -OAuth is not currently supported. +OAuth is not currently supported. An operation that accepts only unsupported schemes is sent without credentials, which can then be given in `config.headers`. If an operation accepts a supported scheme whose credentials are not configured, `Authenticate` signals an error naming the missing setting. + +## `joinArray` + +Joins an array query parameter into one value, for parameters the spec declares with `explode: false`. The left argument is the delimiter for the parameter's style (`,` for `form`, a space for `spaceDelimited`, `|` for `pipeDelimited`). A single string or number is passed through as it is. + +## `encodeQuery` + +URL-encodes the query parameters of a request, held in a namespace, as `name=value` pairs. Parameters whose names are not valid APL are held under their mangled names and are restored to their original names here. A parameter whose value is a vector of strings or numbers is repeated, once for each item. + +## `toText` + +Formats a parameter value for a URL. A character vector is returned as it is; a number is formatted as in JSON, so that a negative number has a `-` (not APL's `¯`) and no precision is lost to `⎕PP`. + +## `formatBody` + +Prepares a JSON request body. A [model](models.md) instance, or a vector of them, is converted to a namespace with the model's `FormatNS` method; any other value is returned unchanged. ## `isValidPathParam` diff --git a/docs/installation.md b/docs/installation.md index 78fb5f7..2bb41d9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ ## Download and Setup -Download the latest binary for your platform from the [GitHub Releases page](https://github.com/Dyalog/OpenAPI/releases/latest). +Download the latest binary for your platform from the [GitHub Releases page](https://github.com/dyalog-labs/OpenAPI/releases/latest). === "Windows" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..7d36d4f --- /dev/null +++ b/install.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_PATH="/usr/local/bin/openapidyalog" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Detect RID +case "$(uname -s)/$(uname -m)" in + Linux/x86_64) RID="linux-x64" ;; + Linux/aarch64) RID="linux-arm64" ;; + Darwin/x86_64) RID="osx-x64" ;; + Darwin/arm64) RID="osx-arm64" ;; + *) + echo "Unsupported platform: $(uname -s) $(uname -m)" >&2 + exit 1 + ;; +esac + +echo "Building for $RID..." +dotnet publish "$SCRIPT_DIR/src/OpenAPIDyalog/OpenAPIDyalog.csproj" \ + -r "$RID" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -c Debug \ + -o "$SCRIPT_DIR/publish/$RID" \ + --nologo + +echo "Installing to $INSTALL_PATH..." +install -m 755 "$SCRIPT_DIR/publish/$RID/OpenAPIDyalog" "$INSTALL_PATH" + +echo "Done. Run: openapidyalog --help" diff --git a/mkdocs.yml b/mkdocs.yml index 975a3b2..f282d0a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: OpenAPI -repo_url: https://github.com/Dyalog/OpenAPI -repo_name: Dyalog/OpenAPI +repo_url: https://github.com/dyalog-labs/OpenAPI +repo_name: dyalog-labs/OpenAPI copyright: Made with Material for MkDocs
Copyright © 1982-$CURRENT_YEAR Dyalog Limited ($BUILD_DATE $GIT_INFO).
Except where otherwise noted, content on this site is licensed under a Creative Commons Attribution 4.0 International licence. theme: @@ -39,6 +39,7 @@ nav: - Client Class: generated-client/client.md - Utilities: generated-client/utils.md - Operation Functions: generated-client/operations.md + - Models: generated-client/models.md - Using the Client: generated-client/using-the-client.md - Error Handling: generated-client/error-handling.md - Examples: diff --git a/src/OpenAPIDyalog.Tests/OpenAPIDyalog.Tests.csproj b/src/OpenAPIDyalog.Tests/OpenAPIDyalog.Tests.csproj index 03658ee..d727b0b 100644 --- a/src/OpenAPIDyalog.Tests/OpenAPIDyalog.Tests.csproj +++ b/src/OpenAPIDyalog.Tests/OpenAPIDyalog.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/OpenAPIDyalog.Tests/Services/EndpointGeneratorServiceTests.cs b/src/OpenAPIDyalog.Tests/Services/EndpointGeneratorServiceTests.cs index ee5385d..1e9ff93 100644 --- a/src/OpenAPIDyalog.Tests/Services/EndpointGeneratorServiceTests.cs +++ b/src/OpenAPIDyalog.Tests/Services/EndpointGeneratorServiceTests.cs @@ -192,4 +192,49 @@ public async Task GenerateEndpointsAsync_NoInlineRequestBody_ReturnsEmptyInlineS Assert.Empty(inlineSchemas); } + + // ── Query parameter serialisation (real templates) ───────────────────── + + [Fact] + public async Task GenerateEndpointsAsync_ArrayQueryParam_HonoursExplode() + { + var document = await LoadDocumentAsync(""" + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/forecast": { + "get": { + "operationId": "getForecast", + "tags": ["weather"], + "parameters": [ + { "name": "hourly", "in": "query", "explode": false, + "schema": { "type": "array", "items": { "type": "string" } } }, + { "name": "ids", "in": "query", "style": "pipeDelimited", "explode": false, + "schema": { "type": "array", "items": { "type": "integer" } } }, + { "name": "tags", "in": "query", + "schema": { "type": "array", "items": { "type": "string" } } }, + { "name": "city", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" } } + } + } + } + } + """); + + var templates = new TemplateService(NullLogger.Instance); + var service = new EndpointGeneratorService(templates, NullLogger.Instance); + await service.GenerateEndpointsAsync(document, _tempDir); + + var file = Directory.GetFiles(_tempDir, "*.aplf", SearchOption.AllDirectories).Single(); + var apl = await File.ReadAllTextAsync(file); + + // explode: false → one delimited value, by style + Assert.Contains("queryParams.hourly←','c.∆.utils.joinArray argsNs.hourly", apl); + Assert.Contains("queryParams.ids←'|'c.∆.utils.joinArray argsNs.ids", apl); + // explode: true (the default for form) and non-array values are passed through unchanged + Assert.Contains("queryParams.tags←argsNs.tags", apl); + Assert.Contains("queryParams.city←argsNs.city", apl); + } } diff --git a/src/OpenAPIDyalog.Tests/Services/GeneratedDocsTests.cs b/src/OpenAPIDyalog.Tests/Services/GeneratedDocsTests.cs new file mode 100644 index 0000000..e0da572 --- /dev/null +++ b/src/OpenAPIDyalog.Tests/Services/GeneratedDocsTests.cs @@ -0,0 +1,689 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using OpenAPIDyalog.Models; +using OpenAPIDyalog.Services; + +namespace OpenAPIDyalog.Tests.Services; + +/// +/// Runs the whole generator, with the real templates, and checks the output: the README and +/// tag pages, and that what they document matches the generated functions and models. +/// +public class GeneratedDocsTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"oad-test-{Guid.NewGuid():N}"); + + public GeneratedDocsTests() => Directory.CreateDirectory(_tempDir); + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private const string Spec = """ + { + "openapi": "3.1.0", + "info": { "title": "Pet Shop", "version": "1.0.0" }, + "servers": [{ "url": "https://example.com/v1" }], + "tags": [{ "name": "pet", "description": "Everything about pets" }], + "paths": { + "/pets": { + "get": { + "tags": ["pet"], "operationId": "listPets", "summary": "List | pets", + "parameters": [ + { "name": "limit", "in": "query", "required": true, "schema": { "type": "integer" } }, + { "name": "X-Trace", "in": "header", "schema": { "type": "string" }, "description": "Line one\nline two" }, + { "name": "session", "in": "cookie", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" } } + }, + "post": { + "tags": ["pet"], "operationId": "addPet", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } }, + "responses": { "201": { "description": "Created" } } + } + }, + "/pets/{petId}": { + "parameters": [{ "name": "petId", "in": "path", "required": true, "schema": { "type": "integer" } }], + "patch": { + "tags": ["pet"], "operationId": "renamePet", + "requestBody": { "content": { "application/json": { "schema": { + "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } } } } }, + "responses": { "200": { "description": "OK" } } + } + }, + "/store/orders": { + "post": { "tags": ["store orders"], "responses": { "200": { "description": "OK" } } } + }, + "/store/refunds": { + "post": { "tags": ["store-orders"], "responses": { "200": { "description": "OK" } } } + }, + "/escape": { + "get": { "tags": ["../../outside"], "operationId": "escape", "responses": { "200": { "description": "OK" } } } + }, + "/health": { "get": { "responses": { "200": { "description": "OK" } } } } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", "description": "A pet", "required": ["name"], + "properties": { + "id": { "type": "integer", "readOnly": true }, + "name": { "type": "string" }, + "level": { "type": "integer", "enum": [1, -1] }, + "mood": { "type": "string", "enum": ["it's fine"] }, + "scores": { "type": "array", "items": { "type": "integer" } }, + "friends": { "type": ["array", "null"], "items": { "$ref": "#/components/schemas/Pet" } } + } + } + } + } + } + """; + + private async Task GenerateAsync(string spec = Spec) + { + var specPath = Path.Combine(_tempDir, "spec.json"); + await File.WriteAllTextAsync(specPath, spec); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spec)); + var (document, _) = await OpenApiDocument.LoadAsync(stream, settings: new OpenApiReaderSettings()); + + var templates = new TemplateService(NullLogger.Instance); + var generator = new CodeGeneratorService( + new ArtifactGeneratorService(templates, NullLogger.Instance), + new EndpointGeneratorService(templates, NullLogger.Instance), + new ModelGeneratorService(templates, NullLogger.Instance), + NullLogger.Instance); + + var output = Path.Combine(_tempDir, "out"); + await generator.GenerateAsync(document!, new GeneratorOptions { SpecificationPath = specPath, OutputDirectory = output }); + return output; + } + + private static string Read(string output, params string[] path) => + File.ReadAllText(Path.Combine([output, .. path])); + + // ── README ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Readme_IsFullyRendered_AndLinksEveryTagPage() + { + var output = await GenerateAsync(); + var readme = Read(output, "README.md"); + + Assert.DoesNotContain("{{", readme); + Assert.DoesNotContain("}}", readme); + + foreach (var page in Directory.GetFiles(Path.Combine(output, "docs"))) + Assert.Contains($"(docs/{Path.GetFileName(page)})", readme); + + Assert.Contains("| `Pet` | A pet |", readme); + Assert.Contains("| `RenamePetRequest` |", readme); + } + + // ── Tag pages ────────────────────────────────────────────────────────── + + [Fact] + public async Task TagPages_AreWrittenInsideDocs_OnePerClientField() + { + var output = await GenerateAsync(); + var docs = Path.Combine(output, "docs"); + + // Every page is directly inside docs/, even for a tag like "../../outside" + var pages = Directory.GetFiles(output, "*.md", SearchOption.AllDirectories) + .Where(f => Path.GetFileName(f) != "README.md") + .ToList(); + Assert.All(pages, p => Assert.Equal(docs, Path.GetDirectoryName(p))); + + // "store orders" and "store-orders" are both client.storeOrders, so share a page + var tagDirs = Directory.GetDirectories(Path.Combine(output, "APLSource", "_tags")).Select(Path.GetFileName); + Assert.Equal(tagDirs.Order(), pages.Select(Path.GetFileNameWithoutExtension).Order()); + Assert.Contains("PostStoreRefunds", Read(output, "docs", "storeOrders.md")); + } + + [Fact] + public async Task TagPage_OperationLinks_MatchAnchors() + { + var output = await GenerateAsync(); + var page = Read(output, "docs", "pet.md"); + + var links = Regex.Matches(page, @"\]\(#([^)]+)\)").Select(m => m.Groups[1].Value).ToList(); + var anchors = Regex.Matches(page, @"").Select(m => m.Groups[1].Value).ToList(); + + Assert.Equal(["ListPets", "AddPet", "RenamePet"], links); + Assert.Equal(links, anchors); + } + + [Fact] + public async Task TagPage_DocumentsTheNamesTheFunctionsRead() + { + var output = await GenerateAsync(); + var page = Read(output, "docs", "pet.md"); + string Function(string name) => Read(output, "APLSource", "_tags", "pet", $"{name}.aplf"); + + // A header whose name is not valid APL is passed under its mangled name + Assert.Contains("⍝ ⍙X⍙45⍙Trace: 'value'", page); + Assert.Contains("argsNs.⎕NC'⍙X⍙45⍙Trace'", Function("ListPets")); + Assert.Contains("headerParams,←'X-Trace' argsNs.⍙X⍙45⍙Trace", Function("ListPets")); + + // Cookie parameters are not read by the function, so are not documented + Assert.DoesNotContain("session", page); + + // A path-item parameter is documented, and read, for each operation on the path + Assert.Contains("petId: 0", page); + Assert.Contains("argsNs.⎕NC'petId'", Function("RenamePet")); + + // The body field is the one the function reads + Assert.Contains(" pet: (", page); + Assert.Contains("argsNs.⎕NC'pet'", Function("AddPet")); + Assert.Contains(" renamePetRequest: (", page); + Assert.Contains("argsNs.⎕NC'renamePetRequest'", Function("RenamePet")); + } + + [Fact] + public async Task TagPage_EscapesTableCells_AndOmitsReadOnlyFields() + { + var output = await GenerateAsync(); + var page = Read(output, "docs", "pet.md"); + + Assert.Contains("| List \\| pets |", page); + Assert.Contains("| Line one line two |", page); + Assert.DoesNotContain("id: 0", page); + } + + // ── Models ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Model_ChecksASingleRequiredFieldByName() + { + var model = Read(await GenerateAsync(), "APLSource", "models", "Pet.aplc"); + + Assert.Contains("missing←((⊂,'name'))~args.⎕NL ¯2 ¯9", model); + } + + [Fact] + public async Task Model_EnumConstants_AreTypedAndDistinct() + { + var model = Read(await GenerateAsync(), "APLSource", "models", "Pet.aplc"); + + Assert.Contains("⍙1: 1", model); + Assert.Contains("⍙⍙45⍙1: ¯1", model); + Assert.Contains("ItSFine: 'it''s fine'", model); + } + + [Fact] + public async Task Model_FormatNS_SkipsReadOnly_AndTreatsNullableArraysAsArrays() + { + var model = Read(await GenerateAsync(), "APLSource", "models", "Pet.aplc"); + var formatNS = model[model.IndexOf("∇ build←FormatNS", StringComparison.Ordinal)..model.IndexOf("∇ inst←FromResponse", StringComparison.Ordinal)]; + + Assert.DoesNotContain("build.id", formatNS); + Assert.Contains("build.scores←vec ⍙v.scores", formatNS); + Assert.Contains("build.friends←asNS¨vec ⍙v.friends", formatNS); + } + + // ── Inline models ────────────────────────────────────────────────────── + + private const string InlineSpec = """ + { + "openapi": "3.1.0", + "info": { "title": "Inline", "version": "1.0.0" }, + "paths": { + "/pets": { + "post": { + "tags": ["pet"], "operationId": "addPet", + "requestBody": { "content": { "application/json": { "schema": { + "type": "object", "required": ["name"], + "allOf": [{ "$ref": "#/components/schemas/Base" }], + "properties": { "name": { "type": "string" } } } } } }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "schemas": { + "Base": { "type": "object", "properties": { "id": { "type": "string" }, "owner": { "type": "string" } } }, + "AddPetRequest": { "type": "object", "properties": { "other": { "type": "string" } } }, + "Dog": { + "type": "object", "required": ["owner"], + "allOf": [{ "$ref": "#/components/schemas/Base" }], + "properties": { "bark": { "type": "string" } } + } + } + } + } + """; + + [Fact] + public async Task InlineModel_DoesNotOverwriteAComponentModel_AndTheDocsUseItsName() + { + var output = await GenerateAsync(InlineSpec); + + Assert.Contains("other", Read(output, "APLSource", "models", "AddPetRequest.aplc")); + Assert.Contains("name", Read(output, "APLSource", "models", "AddPetRequest2.aplc")); + Assert.Contains("models.AddPetRequest2", Read(output, "docs", "pet.md")); + Assert.Contains("| `AddPetRequest2` |", Read(output, "README.md")); + } + + [Fact] + public async Task InlineModel_IncludesPropertiesInheritedFromComponents() + { + var model = Read(await GenerateAsync(InlineSpec), "APLSource", "models", "AddPetRequest2.aplc"); + + Assert.Contains(":Property owner", model); + } + + [Fact] + public async Task Model_InheritedPropertyIsRequired_WhenTheOuterSchemaSaysSo() + { + var model = Read(await GenerateAsync(InlineSpec), "APLSource", "models", "Dog.aplc"); + + Assert.Contains("missing←((⊂,'owner'))~args.⎕NL ¯2 ¯9", model); + } + + // ── Schemas that are not objects, deep inheritance and name clashes ──── + + private const string SchemasSpec = """ + { + "openapi": "3.1.0", + "info": { "title": "Schemas", "version": "1.0.0" }, + "paths": { + "/pets": { + "post": { + "tags": ["pet"], "operationId": "addPets", + "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PetList" } } } }, + "responses": { "200": { "description": "OK" } } + } + }, + "/dogs": { + "post": { + "tags": ["pet"], "operationId": "addDog", + "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Dog" } } } }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "schemas": { + "Animal": { "type": "object", "required": ["species"], "properties": { "species": { "type": "string" } } }, + "Pet": { + "type": "object", "required": ["name"], + "allOf": [{ "$ref": "#/components/schemas/Animal" }], + "properties": { "name": { "type": "string" } } + }, + "Dog": { + "type": "object", + "allOf": [{ "$ref": "#/components/schemas/Pet" }], + "properties": { + "foo-bar": { "type": "string", "enum": ["a"] }, + "foo_bar": { "type": "string", "enum": ["b"] }, + "litter": { "$ref": "#/components/schemas/PetList" } + } + }, + "PetList": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } } + } + } + } + """; + + [Fact] + public async Task ReferenceToAnArrayOfModels_IsUsedAsAVectorOfModels() + { + var output = await GenerateAsync(SchemasSpec); + var page = Read(output, "docs", "pet.md"); + + // No class for the array schema itself + Assert.False(File.Exists(Path.Combine(output, "APLSource", "models", "PetList.aplc"))); + Assert.DoesNotContain("PetList", Read(output, "README.md")); + + // As a request body: a vector of Pets, sent as a JSON array + Assert.Contains(" petList: ,⊂(", page); + Assert.Contains("a vector of namespaces or `models.Pet` instances", page); + Assert.Contains("body←,c.∆.utils.formatBody argsNs.petList", Read(output, "APLSource", "_tags", "pet", "AddPets.aplf")); + + // As a property: a vector of Pet instances when read from a response + var dog = Read(output, "APLSource", "models", "Dog.aplc"); + Assert.Contains("build.litter←asNS¨vec ⍙v.litter", dog); + Assert.Contains(".##.Pet).FromResponse ⍵}¨ns.litter", dog); + } + + [Fact] + public async Task InheritedProperties_AreIncludedAtAnyDepth() + { + var output = await GenerateAsync(SchemasSpec); + + Assert.Contains(":Property species", Read(output, "APLSource", "models", "Dog.aplc")); + Assert.Contains("missing←((⊂,'name'),(⊂,'species'))~args.⎕NL ¯2 ¯9", Read(output, "APLSource", "models", "Dog.aplc")); + Assert.Contains(" species: 'value'", Read(output, "docs", "pet.md")); + } + + [Fact] + public async Task EnumConstants_HaveDistinctNames_WhenPropertyNamesDifferOnlyInPunctuation() + { + var dog = Read(await GenerateAsync(SchemasSpec), "APLSource", "models", "Dog.aplc"); + + Assert.Contains(":field public shared Enum⍙foo⍙45⍙bar", dog); + Assert.Contains(":field public shared Enumfoo_bar", dog); + } + + [Fact] + public async Task SchemaNamesThatMakeTheSameClass_StopGeneration() + { + var spec = SchemasSpec.Replace("\"Animal\": {", "\"animal\": { \"type\": \"object\" }, \"Animal\": {"); + + var ex = await Assert.ThrowsAsync(() => GenerateAsync(spec)); + Assert.Contains("animal, Animal", ex.Message); + } + + // ── Free-form objects, aliases, nullable arrays and enums ────────────── + + private const string NullableSpec = """ + { + "openapi": "3.1.0", + "info": { "title": "Nullable", "version": "1.0.0" }, + "paths": { + "/items": { + "get": { + "tags": ["item"], "operationId": "listItems", + "parameters": [{ "name": "ids", "in": "query", "explode": false, + "schema": { "type": ["array", "null"], "items": { "type": "string" } } }], + "responses": { "200": { "description": "OK" } } + }, + "post": { + "tags": ["item"], "operationId": "addItem", + "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Alias" } } } }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "properties": { + "colour": { "type": ["string", "null"], "enum": ["red", "blue", null] }, + "extra": { "$ref": "#/components/schemas/Extra" } + } + }, + "Extra": { "type": "object" }, + "Alias": { "$ref": "#/components/schemas/Middle" }, + "Middle": { "$ref": "#/components/schemas/Item" } + } + } + } + """; + + [Fact] + public async Task NullableArrayQueryParam_WithExplodeFalse_IsJoined() + { + var apl = Read(await GenerateAsync(NullableSpec), "APLSource", "_tags", "item", "ListItems.aplf"); + + Assert.Contains("queryParams.ids←','c.∆.utils.joinArray argsNs.ids", apl); + } + + [Fact] + public async Task NullableEnum_AcceptsNull() + { + var item = Read(await GenerateAsync(NullableSpec), "APLSource", "models", "Item.aplc"); + + Assert.Contains(":AndIf (⊂'null')≢args.NewValue", item); + Assert.Contains("must be one of: red, blue, null", item); + } + + [Fact] + public async Task FreeFormObject_IsAMap() + { + var extra = Read(await GenerateAsync(NullableSpec), "APLSource", "models", "Extra.aplc"); + + Assert.Contains(":field _data", extra); + } + + [Fact] + public async Task ReferenceChain_ResolvesToTheModel() + { + var output = await GenerateAsync(NullableSpec); + + Assert.Contains(":Property colour", Read(output, "APLSource", "models", "Alias.aplc")); + Assert.Contains("a namespace or a `models.Alias` instance", Read(output, "docs", "item.md")); + Assert.Contains("colour: 'red'", Read(output, "docs", "item.md")); + } + + [Fact] + public async Task Model_AcceptsAndSendsANestedModel() + { + var item = Read(await GenerateAsync(NullableSpec), "APLSource", "models", "Item.aplc"); + + // A nested model is a namespace or an instance (name class 9), not only an array (2) + Assert.Contains(":If (args.⎕NC 'extra')∊2 9 ⋄ extra←args.extra ⋄ :EndIf", item); + Assert.Contains(":If (⍙v.⎕NC 'extra')∊2 9", item); + } + + // ── Member names, line breaks and primitive allOf ────────────────────── + + private const string MembersSpec = """ + { + "openapi": "3.1.0", + "info": { "title": "Members", "version": "1.0.0" }, + "paths": { + "/things": { + "post": { + "tags": ["thing"], "operationId": "addThing", + "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } } }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "schemas": { + "Thing": { + "type": "object", + "properties": { + "FormatNS": { "type": "string" }, + "args": { "type": "string" }, + "foo": { "type": "string" }, + "_foo": { "type": "string" }, + "mood": { "type": "string", "enum": ["calm", "two\nlines"] }, + "EnumMood": { "type": "string" }, + "code": { "$ref": "#/components/schemas/Code" } + } + }, + "Code": { "allOf": [{ "type": "string" }] } + } + } + } + """; + + [Fact] + public async Task PropertyNamesThatClashWithClassMembers_AreRenamed() + { + var thing = Read(await GenerateAsync(MembersSpec), "APLSource", "models", "Thing.aplc"); + + Assert.Contains(":Property FormatNS_", thing); + Assert.Contains(":Property args_", thing); + Assert.Contains(":Property EnumMood_", thing); + Assert.Contains(":If (args.⎕NC 'FormatNS')∊2 9 ⋄ FormatNS_←args.FormatNS ⋄ :EndIf", thing); + // foo and _foo are both plain properties, as values are kept in ⍙v + Assert.Contains(":Property foo\n", thing); + Assert.Contains(":Property _foo\n", thing); + } + + [Fact] + public async Task StringLiterals_WriteLineBreaksAsCodes() + { + var thing = Read(await GenerateAsync(MembersSpec), "APLSource", "models", "Thing.aplc"); + + Assert.Contains("('two',(⎕UCS 10),'lines')", thing); + Assert.Contains("must be one of: calm, two lines'", thing); + } + + [Fact] + public async Task AllOfOfAPrimitive_IsNotAModel() + { + var output = await GenerateAsync(MembersSpec); + + Assert.False(File.Exists(Path.Combine(output, "APLSource", "models", "Code.aplc"))); + Assert.DoesNotContain("⍙v.code←(⎕NEW", Read(output, "APLSource", "models", "Thing.aplc")); + } + + [Fact] + public async Task InlineFreeFormBody_HasAMapModel() + { + var output = await GenerateAsync(MembersSpec); + + Assert.Contains(":field _data", Read(output, "APLSource", "models", "AddThingRequest.aplc")); + Assert.Contains("argsNs.⎕NC'addThingRequest'", Read(output, "APLSource", "_tags", "thing", "AddThing.aplf")); + } + + // ── Function name clashes, optional bodies and additional properties ─── + + [Fact] + public async Task OperationsThatMakeTheSameFunction_StopGeneration() + { + var spec = Spec.Replace("\"operationId\": \"renamePet\"", "\"operationId\": \"list_pets\""); + + var ex = await Assert.ThrowsAsync(() => GenerateAsync(spec)); + Assert.Contains("GET /pets, PATCH /pets/{petId} (all client.pet.ListPets)", ex.Message); + } + + [Fact] + public async Task OptionalBody_IsOnlySentWhenGiven() + { + var apl = Read(await GenerateAsync(), "APLSource", "_tags", "pet", "RenamePet.aplf"); + + Assert.DoesNotContain("Params: body", apl); + Assert.Contains(":If hasBody", apl); + Assert.Contains(" clientArgs.Params←body", apl); + } + + [Fact] + public async Task Model_KeepsAdditionalProperties_UnlessTheSchemaForbidsThem() + { + var output = await GenerateAsync(Spec.Replace( + "\"type\": \"object\", \"description\": \"A pet\"", + "\"type\": \"object\", \"description\": \"A pet\", \"additionalProperties\": false")); + var pet = Read(output, "APLSource", "models", "Pet.aplc"); + Assert.DoesNotContain("additional properties", pet); + + var renamed = Read(output, "APLSource", "models", "RenamePetRequest.aplc"); + Assert.Contains("⍙n←(args.⎕NL ¯2 ¯9)~((⊂,'name'),⍬)", renamed); + Assert.Contains("build ⎕VSET", renamed); + } + + // ── Faults and quirks found in real specs (OpenAI, Open-Meteo, Petstore) ─ + + private const string QuirksSpec = """ + { + "openapi": "3.0.3", + "info": { "title": "Quirks", "version": "1.0.0" }, + "paths": { + "/certs/{certificate_id}": { + "servers": [{ "url": "/api/v3" }], + "get": { + "tags": ["certs"], "operationId": "getCert", + "parameters": [{ "name": "cert_id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { "200": { "description": "OK" } } + }, + "post": { + "tags": ["certs"], "operationId": "uploadCert", + "security": [{ "oauth": [] }], + "requestBody": { "content": { "multipart/form-data": { "schema": { + "type": "object", "properties": { "file": { "type": "string", "format": "binary" }, + "include[]": { "type": "array", "items": { "type": "string" } } } } } } }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "securitySchemes": { "oauth": { "type": "oauth2", "flows": { "implicit": { + "authorizationUrl": "https://example.com/auth", "scopes": {} } } } } + } + } + """; + + [Fact] + public async Task PathPlaceholders_AreTheFunctionsPathParameters() + { + var output = await GenerateAsync(QuirksSpec); + var apl = Read(output, "APLSource", "_tags", "certs", "GetCert.aplf"); + var page = Read(output, "docs", "certs.md"); + + // The undeclared {certificate_id} is required; the misnamed cert_id is ignored + Assert.Contains("argsNs.⎕NC'certificate_id'", apl); + Assert.DoesNotContain("cert_id'", apl); + Assert.Contains(" certificate_id: 'value'", page); + Assert.DoesNotContain("`cert_id`", page); + } + + [Fact] + public async Task MultipartField_ThatIsNotAValidAplName_FailsClearly() + { + var output = await GenerateAsync(QuirksSpec); + + Assert.Contains("Form field include[] cannot be sent", Read(output, "APLSource", "_tags", "certs", "UploadCert.aplf")); + Assert.Contains("cannot be sent: include[] is not a valid APL name", Read(output, "docs", "certs.md")); + } + + [Fact] + public async Task Readme_UsesAPathLevelServer_AndFlagsARelativeOne() + { + var readme = Read(await GenerateAsync(QuirksSpec), "README.md"); + + Assert.Contains("baseUrl: 'https://your-api-server.com/api/v3' ⍝ the spec gives only the path", readme); + Assert.Contains("response ← client.certs.GetCert args", readme); + } + + [Fact] + public async Task Readme_ExampleIsTheFirstGet() + { + var readme = Read(await GenerateAsync(), "README.md"); + + Assert.Contains("response ← client.pet.ListPets args", readme); + } + + [Fact] + public async Task Utils_SendsOnlyUnsupportedSchemesAsIs_AndNumbersAsJson() + { + var utils = Read(await GenerateAsync(QuirksSpec), "APLSource", "utils.apln"); + + Assert.DoesNotContain("No valid/supported security scheme available", utils); + Assert.Contains("API key not configured (config.security.apiKey)", utils); + Assert.Contains("c.∆ ⎕VGET (↑'config.security.'", utils); + Assert.Contains("1 ⎕JSON ⍵", utils); + Assert.Contains("argsNs.certificate_id←c.∆.utils.toText argsNs.certificate_id", + Read(await GenerateAsync(QuirksSpec), "APLSource", "_tags", "certs", "GetCert.aplf")); + } + + [Fact] + public async Task Utils_CombinesHeadersFromEverySourceAsAMatrix() + { + var utils = Read(await GenerateAsync(), "APLSource", "utils.apln"); + + Assert.Contains("cArgs.Headers←(asHeaders cArgs.Headers)⍪asHeaders 'User-Agent'", utils); + Assert.Contains("cArgs.Headers⍪←asHeaders c.∆.config ⎕VGET ⊂'headers' ⍬", utils); + } + + [Fact] + public async Task RequiredMultipartBody_IsAlwaysSent() + { + var spec = QuirksSpec.Replace("\"requestBody\": { \"content\": { \"multipart", "\"requestBody\": { \"required\": true, \"content\": { \"multipart"); + var apl = Read(await GenerateAsync(spec), "APLSource", "_tags", "certs", "UploadCert.aplf"); + + Assert.Contains("hasBody←1 ⍝ a required body is always sent", apl); + } + + [Fact] + public async Task EnumValues_OfDifferentTypesButTheSameText_HaveDistinctNames() + { + var spec = MembersSpec.Replace("[\"calm\", \"two\\nlines\"]", "[\"1\", 1]"); + Assert.NotEqual(MembersSpec, spec); + + var thing = Read(await GenerateAsync(spec), "APLSource", "models", "Thing.aplc"); + Assert.Contains("⍙1: '1'", thing); + Assert.Contains("⍙1_2: 1", thing); + } +} diff --git a/src/OpenAPIDyalog.Tests/Services/OpenApiServiceTests.cs b/src/OpenAPIDyalog.Tests/Services/OpenApiServiceTests.cs new file mode 100644 index 0000000..7c7db1c --- /dev/null +++ b/src/OpenAPIDyalog.Tests/Services/OpenApiServiceTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging.Abstractions; +using OpenAPIDyalog.Services; + +namespace OpenAPIDyalog.Tests.Services; + +public class OpenApiServiceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"oad-test-{Guid.NewGuid():N}"); + + public OpenApiServiceTests() => Directory.CreateDirectory(_tempDir); + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + // ── Helper ───────────────────────────────────────────────────────────── + + private string WriteSpec(string json) + { + var path = Path.Combine(_tempDir, "spec.json"); + File.WriteAllText(path, json); + return path; + } + + private static readonly OpenApiService Service = new(NullLogger.Instance); + + // "example" is not a valid property of a Response Object, so the reader reports an error + private const string SpecWithReaderError = """ + { + "openapi": "3.0.3", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/items": { + "get": { + "operationId": "ListItems", + "responses": { + "401": { "description": "Unauthorized", "example": { "error": 401 } } + } + } + } + } + } + """; + + // ── LoadSpecificationAsync ───────────────────────────────────────────── + + [Fact] + public async Task LoadSpecification_ValidSpec_Succeeds() + { + var path = WriteSpec(""" + { + "openapi": "3.0.3", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { "/items": { "get": { "operationId": "ListItems", "responses": { "200": { "description": "OK" } } } } } + } + """); + + var result = await Service.LoadSpecificationAsync(path); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Document); + } + + [Fact] + public async Task LoadSpecification_ReaderError_FailsByDefault() + { + var result = await Service.LoadSpecificationAsync(WriteSpec(SpecWithReaderError)); + + Assert.False(result.IsSuccess); + Assert.NotEmpty(result.Diagnostic!.Errors); + } + + [Fact] + public async Task LoadSpecification_ReaderError_SucceedsWithValidationDisabled() + { + var result = await Service.LoadSpecificationAsync(WriteSpec(SpecWithReaderError), disableValidation: true); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Document); + Assert.NotEmpty(result.Diagnostic!.Errors); // still reported, for the caller to log as warnings + Assert.Contains("/items", result.Document!.Paths.Keys); + } +} diff --git a/src/OpenAPIDyalog.Tests/Utils/OperationNamingTests.cs b/src/OpenAPIDyalog.Tests/Utils/OperationNamingTests.cs new file mode 100644 index 0000000..b127295 --- /dev/null +++ b/src/OpenAPIDyalog.Tests/Utils/OperationNamingTests.cs @@ -0,0 +1,179 @@ +using System.Text; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using OpenAPIDyalog.Utils; + +namespace OpenAPIDyalog.Tests.Utils; + +public class OperationNamingTests +{ + private static async Task LoadDocumentAsync(string json) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var (document, _) = await OpenApiDocument.LoadAsync(stream, settings: new OpenApiReaderSettings()); + return document!; + } + + private static OpenApiOperation Operation(OpenApiDocument document, string path) => + document.Paths[path].Operations!.Values.Single(); + + // ── Names ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("listPets", "get", "/pets", "ListPets")] + [InlineData("list_pets", "get", "/pets", "ListPets")] + [InlineData(null, "GET", "/pets/{petId}", "GetPetsPetId")] + public void FunctionName_UsesOperationIdOrMethodAndPath(string? operationId, string method, string path, string expected) + { + Assert.Equal(expected, OperationNaming.FunctionName(operationId, method, path)); + } + + [Theory] + [InlineData("pet", "pet")] + [InlineData("store orders", "storeOrders")] + [InlineData("store-orders", "storeOrders")] + public void TagName_IsCamelCase(string tag, string expected) + { + Assert.Equal(expected, OperationNaming.TagName(tag)); + } + + [Theory] + [InlineData("../../outside")] + [InlineData("a/b")] + [InlineData("..")] + public void TagName_IsSafeAsAFileName(string tag) + { + var name = OperationNaming.TagName(tag); + + Assert.DoesNotContain('/', name); + Assert.DoesNotContain('\\', name); + Assert.DoesNotContain('.', name); + } + + // ── Parameters ───────────────────────────────────────────────────────── + + [Fact] + public async Task MergeParameters_IncludesPathItemParameters_OverriddenByNameAndLocation() + { + var document = await LoadDocumentAsync(""" + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/pets/{id}": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "trace", "in": "header", "schema": { "type": "string" } } + ], + "get": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "description": "op", "schema": { "type": "integer" } }, + { "name": "trace", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" } } + } + } + } + } + """); + + var parameters = OperationNaming.MergeParameters("/pets/{id}", document.Paths["/pets/{id}"], Operation(document, "/pets/{id}")); + + Assert.Equal(3, parameters.Count); + Assert.Equal("op", parameters.Single(p => p.Name == "id").Description); + Assert.Contains(parameters, p => p.Name == "trace" && p.In == ParameterLocation.Header); + Assert.Contains(parameters, p => p.Name == "trace" && p.In == ParameterLocation.Query); + } + + // ── JSON request bodies ──────────────────────────────────────────────── + + private static async Task<(IOpenApiSchema Schema, OpenApiDocument Document)> BodySchemaAsync(string schemaJson) + { + var document = await LoadDocumentAsync($$""" + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "components": { "schemas": { + "Pet": { "type": "object", "properties": { "name": { "type": "string" } } }, + "PetList": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } }, + "Note": { "type": "string" } } }, + "paths": { + "/x": { + "post": { + "requestBody": { "content": { "application/json": { "schema": {{schemaJson}} } } }, + "responses": { "200": { "description": "OK" } } + } + } + } + } + """); + return (Operation(document, "/x").RequestBody!.Content!["application/json"].Schema!, document); + } + + private static async Task DescribeAsync(string schemaJson, string functionName) + { + var (schema, document) = await BodySchemaAsync(schemaJson); + return OperationNaming.DescribeJsonBody(schema, functionName, document); + } + + [Fact] + public async Task DescribeJsonBody_Reference_NamedAfterSchema() + { + var body = await DescribeAsync("""{ "$ref": "#/components/schemas/Pet" }""", "AddPet"); + + Assert.NotNull(body); + Assert.Equal("pet", body.ArgName); + Assert.Equal("Pet", body.ModelName); + Assert.False(body.IsArray); + Assert.Null(body.InlineSchema); + } + + [Fact] + public async Task DescribeJsonBody_NullableArrayOfReferences_IsAnArray() + { + var body = await DescribeAsync("""{ "type": ["array", "null"], "items": { "$ref": "#/components/schemas/Pet" } }""", "AddPets"); + + Assert.NotNull(body); + Assert.Equal("pet", body.ArgName); + Assert.True(body.IsArray); + } + + [Fact] + public async Task DescribeJsonBody_InlineObject_NamedAfterOperation() + { + var body = await DescribeAsync("""{ "type": "object", "properties": { "name": { "type": "string" } } }""", "RenamePet"); + + Assert.NotNull(body); + Assert.Equal("renamePetRequest", body.ArgName); + Assert.Equal("RenamePetRequest", body.ModelName); + Assert.NotNull(body.InlineSchema); + } + + [Fact] + public async Task DescribeJsonBody_Primitive_HasNoModel() + { + Assert.Null(await DescribeAsync("""{ "type": "string" }""", "Note")); + } + + [Fact] + public async Task DescribeJsonBody_ReferenceToAnArrayOfModels_IsAnArrayOfTheItemModel() + { + var body = await DescribeAsync("""{ "$ref": "#/components/schemas/PetList" }""", "AddPets"); + + Assert.NotNull(body); + Assert.Equal("petList", body.ArgName); + Assert.Equal("Pet", body.ModelName); + Assert.True(body.IsArray); + } + + [Fact] + public async Task DescribeJsonBody_ReferenceToAPrimitive_HasNoModel() + { + var body = await DescribeAsync("""{ "$ref": "#/components/schemas/Note" }""", "AddNote"); + + Assert.NotNull(body); + Assert.Equal("note", body.ArgName); + Assert.Null(body.ModelName); + Assert.False(body.IsArray); + } +} diff --git a/src/OpenAPIDyalog.Tests/Utils/PathConverterTests.cs b/src/OpenAPIDyalog.Tests/Utils/PathConverterTests.cs index c817625..c63ec07 100644 --- a/src/OpenAPIDyalog.Tests/Utils/PathConverterTests.cs +++ b/src/OpenAPIDyalog.Tests/Utils/PathConverterTests.cs @@ -13,14 +13,14 @@ public void ToDyalogPath_NoParams_ReturnsSingleQuotedSegment() [Fact] public void ToDyalogPath_SingleParam_ReturnsConcatenation() { - Assert.Equal("'/users/',(⍕argsNs.userId)", PathConverter.ToDyalogPath("/users/{userId}")); + Assert.Equal("'/users/',(c.∆.HttpCommand.UrlEncode⍕argsNs.userId)", PathConverter.ToDyalogPath("/users/{userId}")); } [Fact] public void ToDyalogPath_MultipleParams_ReturnsFullConcatenation() { var result = PathConverter.ToDyalogPath("/users/{userId}/posts/{postId}"); - Assert.Equal("'/users/',(⍕argsNs.userId),'/posts/',(⍕argsNs.postId)", result); + Assert.Equal("'/users/',(c.∆.HttpCommand.UrlEncode⍕argsNs.userId),'/posts/',(c.∆.HttpCommand.UrlEncode⍕argsNs.postId)", result); } [Fact] @@ -35,7 +35,7 @@ public void ToDyalogPath_HyphenatedParam_EscapesViaToValidAplName() // "item-id" contains a hyphen (UCS 45) which is invalid in APL names. // ToValidAplName encodes it, so the result must contain the encoded form. var result = PathConverter.ToDyalogPath("/items/{item-id}"); - Assert.StartsWith("'/items/',(⍕argsNs.⍙", result); + Assert.StartsWith("'/items/',(c.∆.HttpCommand.UrlEncode⍕argsNs.⍙", result); Assert.Contains("45", result); // '45' is the UCS code for '-' } } diff --git a/src/OpenAPIDyalog/Constants/GeneratorConstants.cs b/src/OpenAPIDyalog/Constants/GeneratorConstants.cs index 35c1f2b..7a939b0 100644 --- a/src/OpenAPIDyalog/Constants/GeneratorConstants.cs +++ b/src/OpenAPIDyalog/Constants/GeneratorConstants.cs @@ -16,6 +16,7 @@ public static class GeneratorConstants public const string UtilsTemplate = "APLSource/utils.apln.scriban"; public const string VersionTemplate = "APLSource/Version.aplf.scriban"; public const string ReadmeTemplate = "README.md.scriban"; + public const string TagDocTemplate = "docs/tag.md.scriban"; public const string ModelTemplate = "APLSource/models/model.aplc.scriban"; public const string HttpCommandResource = "APLSource/HttpCommand.aplc"; public const string ThirdPartyNoticesResource = "OpenAPIDyalog.THIRD_PARTY_NOTICES.txt"; diff --git a/src/OpenAPIDyalog/GeneratorApplication.cs b/src/OpenAPIDyalog/GeneratorApplication.cs index 19def38..ff4e7c1 100644 --- a/src/OpenAPIDyalog/GeneratorApplication.cs +++ b/src/OpenAPIDyalog/GeneratorApplication.cs @@ -67,7 +67,7 @@ public static async Task RunAsync(string[] args) var openApiService = new OpenApiService(loggerFactory.CreateLogger()); var artifactService = new ArtifactGeneratorService(templateService, loggerFactory.CreateLogger()); var endpointService = new EndpointGeneratorService(templateService, loggerFactory.CreateLogger()); - var modelService = new ModelGeneratorService(loggerFactory.CreateLogger()); + var modelService = new ModelGeneratorService(templateService, loggerFactory.CreateLogger()); var codeGen = new CodeGeneratorService(artifactService, endpointService, modelService, loggerFactory.CreateLogger()); @@ -88,6 +88,14 @@ public static async Task RunAsync(string[] args) return 1; } + if (result.Diagnostic?.Errors?.Count > 0) + { + appLogger.LogWarning("Continuing despite {Count} error(s) in the specification (--no-validation):", + result.Diagnostic.Errors.Count); + foreach (var error in result.Diagnostic.Errors) + appLogger.LogWarning(" {Pointer} — {Message}", error.Pointer, error.Message); + } + var document = result.Document!; // Log document info (replaces DisplayDocumentInfo / DisplayPaths). diff --git a/src/OpenAPIDyalog/Models/DocsModel.cs b/src/OpenAPIDyalog/Models/DocsModel.cs new file mode 100644 index 0000000..f3074ab --- /dev/null +++ b/src/OpenAPIDyalog/Models/DocsModel.cs @@ -0,0 +1,109 @@ +namespace OpenAPIDyalog.Models; + +/// +/// The operations under one tag, as documented in README.md and docs/<tag>.md. +/// +public class TagDoc +{ + /// The tag as written in the spec (the first one, if several map to the same APL name). + public string Name { get; set; } = string.Empty; + + /// The APL name: the Client field (client.<AplName>) and the docs file name. + public string AplName { get; set; } = string.Empty; + + /// A heading for the tag, e.g. "store-orders" → "Store Orders". + public string DisplayName { get; set; } = string.Empty; + + /// The tag's description from the spec's top-level tags list. + public string? Description { get; set; } + + /// Path of the tag's page, relative to the output directory. + public string DocPath => $"docs/{AplName}.md"; + + public List Operations { get; set; } = new(); +} + +/// +/// One operation, as documented on its tag's page. +/// +public class OperationDoc +{ + /// The generated function's name, also the anchor of its section. + public string FunctionName { get; set; } = string.Empty; + + public string? OperationId { get; set; } + public string Method { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string? Summary { get; set; } + public string? Description { get; set; } + public bool Deprecated { get; set; } + + /// Parameters the generated function reads from its argument namespace. + public List Parameters { get; set; } = new(); + + /// The request body, or null if the operation takes none. + public RequestBodyDoc? RequestBody { get; set; } + + public List Responses { get; set; } = new(); + + /// A complete, runnable APL example of calling the operation. + public string UsageExample { get; set; } = string.Empty; +} + +/// +/// A path, query or header parameter. +/// +public class ParameterDoc +{ + /// The name as written in the spec, and as sent to the server. + public string Name { get; set; } = string.Empty; + + /// The field to set on the argument namespace; differs from Name only when that is not valid APL. + public string AplName { get; set; } = string.Empty; + + /// "path", "query" or "header". + public string Location { get; set; } = string.Empty; + + public bool Required { get; set; } + + /// A short type description, e.g. str, int, array[str] or a model name. + public string Type { get; set; } = string.Empty; + + public string? Description { get; set; } +} + +/// +/// An operation's request body. +/// +public class RequestBodyDoc +{ + public string ContentType { get; set; } = string.Empty; + public bool Required { get; set; } + + /// The field on the argument namespace that holds the body (absent for multipart, where each form field is its own). + public string? ArgName { get; set; } + + /// The model class for a JSON body (or its items), if one is generated. + public string? ModelName { get; set; } + + /// Whether a JSON body is an array. + public bool IsArray { get; set; } +} + +/// +/// One documented response. +/// +public class ResponseDoc +{ + public string Status { get; set; } = string.Empty; + public string? Description { get; set; } +} + +/// +/// A generated model class, as listed in README.md. +/// +public class ModelDoc +{ + public string ClassName { get; set; } = string.Empty; + public string? Description { get; set; } +} diff --git a/src/OpenAPIDyalog/Models/ModelTemplateContext.cs b/src/OpenAPIDyalog/Models/ModelTemplateContext.cs index 7775b10..45b3332 100644 --- a/src/OpenAPIDyalog/Models/ModelTemplateContext.cs +++ b/src/OpenAPIDyalog/Models/ModelTemplateContext.cs @@ -1,4 +1,6 @@ +using CaseConverter; using Microsoft.OpenApi; +using OpenAPIDyalog.Utils; namespace OpenAPIDyalog.Models; @@ -21,6 +23,45 @@ public class ModelTemplateContext /// List of properties in this model. /// public List Properties { get; set; } = new(); + + /// + /// Whether this is a map/dictionary type (has additionalProperties but no named properties). + /// + public bool IsMapType { get; set; } + + /// + /// The APL type of the additionalProperties values, if this is a map type. + /// + public string? MapValueType { get; set; } + + /// + /// Whether the schema allows properties beyond those it declares (OpenAPI's default), which a + /// model then keeps and sends as given. + /// + public bool AllowsExtraProperties { get; set; } + + /// + /// Whether any property has enum values (used to conditionally emit shared fields). + /// + public bool HasEnums => Properties.Any(p => p.HasEnumValues); + + /// + /// Properties that have enum values. + /// + public IEnumerable EnumProperties => Properties.Where(p => p.HasEnumValues); + + /// + /// Whether any non-readOnly property is required (used in make1 validation). + /// ReadOnly fields are server-generated and cannot be required from the caller. + /// + public bool HasWritableRequiredFields => Properties.Any(p => p.IsRequired && !p.IsReadOnly); + + /// + /// DyalogName values for required non-readOnly properties — what make1 callers must provide. + /// + public IEnumerable WritableRequiredFields => Properties + .Where(p => p.IsRequired && !p.IsReadOnly) + .Select(p => p.DyalogName); } /// @@ -29,17 +70,26 @@ public class ModelTemplateContext public class ModelProperty { /// - /// The property name as it appears in the API (snake_case). + /// The property name as it appears in the API (raw JSON key). + /// Used in comments only; DyalogName is used in generated APL code. /// public string ApiName { get; set; } = string.Empty; /// - /// The property name for Dyalog APL (camelCase). + /// The APL-safe property name (ToValidAplName applied to the raw JSON key). + /// Used for :Property declarations, backing variables (_DyalogName), and namespace members. + /// ⎕JSON will correctly round-trip mangled names back to the original JSON key. /// public string DyalogName { get; set; } = string.Empty; /// - /// The type of the property (string, int, bool, etc.). + /// The name of the class's property. This is DyalogName, unless that would clash with a member + /// the class itself defines (FormatNS, an Enum… constants namespace, …), when "_" is appended. + /// + public string MemberName { get; set; } = string.Empty; + + /// + /// The APL type string (str, int, bool, array[T], namespace, or a model class name). /// public string Type { get; set; } = "any"; @@ -48,18 +98,43 @@ public class ModelProperty /// public bool IsRequired { get; set; } + /// + /// Whether this property permits null values (⊂'null' in APL / null in JSON). + /// + public bool IsNullable { get; set; } + + /// + /// Whether this is a server-generated read-only field (cannot be set, and skipped in FormatNS). + /// + public bool IsReadOnly { get; set; } + + /// + /// Whether this is a write-only field (e.g. passwords). + /// + public bool IsWriteOnly { get; set; } + + /// + /// OpenAPI format annotation (uuid, date-time, email, etc.). + /// + public string? Format { get; set; } + + /// + /// Default value rendered as a string, if declared in the schema. + /// + public string? DefaultValue { get; set; } + /// /// Description of the property. /// public string? Description { get; set; } /// - /// Whether this is a reference to another model. + /// Whether this is a reference to another model class. /// public bool IsReference { get; set; } /// - /// The referenced type name if this is a reference. + /// The referenced model class name, if this is a reference. /// public string? ReferenceType { get; set; } @@ -67,4 +142,58 @@ public class ModelProperty /// Whether this property is an array. /// public bool IsArray { get; set; } + + /// + /// Whether this property is an array of strings (so a single string must be enclosed to stay one item). + /// + public bool IsStringArray { get; set; } + + /// + /// Allowed enum values, or null if the property is not an enum. + /// + public List? EnumValues { get; set; } + + /// + /// Whether this property has enum values. + /// + public bool HasEnumValues => EnumValues?.Count > 0; + + /// + /// Whether an enum property may also be null (its schema is nullable, or lists null as a value). + /// + public bool EnumAllowsNull { get; set; } + + /// + /// The name of the enum constants namespace, after "Enum" (e.g. "role" → "Role"). + /// Derived from ApiName (raw JSON key) to stay clean even when DyalogName is mangled; + /// set to DyalogName where that would make two properties' names the same. + /// + public string EnumFieldName + { + get => _enumFieldName ?? StringHelpers.ToValidAplName(ApiName.ToPascalCase()); + set => _enumFieldName = value; + } + + private string? _enumFieldName; +} + +/// +/// A single allowed value for an enum property. +/// +public class EnumValue +{ + /// + /// The raw API value (e.g. "admin"). + /// + public string ApiValue { get; set; } = string.Empty; + + /// + /// The APL identifier for this value (e.g. "Admin"). + /// + public string AplName { get; set; } = string.Empty; + + /// + /// The value as an APL literal of the right type (e.g. 'admin', 42 or (⊂'true')). + /// + public string AplLiteral { get; set; } = string.Empty; } diff --git a/src/OpenAPIDyalog/Models/OperationTemplateContext.cs b/src/OpenAPIDyalog/Models/OperationTemplateContext.cs index 71cf724..beceba1 100644 --- a/src/OpenAPIDyalog/Models/OperationTemplateContext.cs +++ b/src/OpenAPIDyalog/Models/OperationTemplateContext.cs @@ -58,10 +58,20 @@ public class OperationTemplateContext public string? RequestContentType { get; set; } /// - /// The name of the model for the JSON request body, if any. + /// The model class for the JSON request body (or its items, for an array body), if any. /// public string? RequestJsonBodyType { get; set; } + /// + /// The field on argsNs that holds the JSON request body. + /// + public string? RequestBodyArgName { get; set; } + + /// + /// Whether the JSON request body is an array. + /// + public bool RequestBodyIsArray { get; set; } + /// /// Responses for this operation. /// diff --git a/src/OpenAPIDyalog/Models/TemplateContext.cs b/src/OpenAPIDyalog/Models/TemplateContext.cs index 0262231..a6ca3ba 100644 --- a/src/OpenAPIDyalog/Models/TemplateContext.cs +++ b/src/OpenAPIDyalog/Models/TemplateContext.cs @@ -1,5 +1,6 @@ using Microsoft.OpenApi; using OpenAPIDyalog.Constants; +using OpenAPIDyalog.Utils; namespace OpenAPIDyalog.Models; @@ -59,7 +60,14 @@ public class ApiTemplateContext : ITemplateContext /// /// Base server URL (first server if available). /// - public string? BaseUrl => Servers.FirstOrDefault()?.Url; + public string? BaseUrl => + (Servers.FirstOrDefault() + ?? Document?.Paths?.Values.SelectMany(p => p.Servers ?? new List()).FirstOrDefault())?.Url; + + /// + /// Whether the base URL is only a path (e.g. "/api/v3"), relative to wherever the spec is served. + /// + public bool BaseUrlIsRelative => BaseUrl?.StartsWith('/') == true; /// /// All security schemes defined in the API, converted to a template-friendly format. @@ -91,6 +99,11 @@ public Dictionary SecuritySchemes } } + /// + /// Whether the API defines any security schemes. + /// + public bool HasSecuritySchemes => Document?.Components?.SecuritySchemes?.Count > 0; + /// /// Additional custom properties for template use. /// @@ -139,57 +152,30 @@ public IEnumerable GetAllTags() } /// - /// Gets all operations grouped by tag. + /// The inline request body schemas found by endpoint generation, keyed by the name of the + /// model generated for each. /// - public Dictionary> GetOperationsByTag() - { - var operationsByTag = new Dictionary>(); - - if (Paths == null) return operationsByTag; - - foreach (var path in Paths) - { - if (path.Value?.Operations == null) continue; - - foreach (var operation in path.Value.Operations) - { - var op = operation.Value; - var tag = op.Tags?.FirstOrDefault()?.Name ?? "default"; + public IReadOnlyDictionary InlineSchemas { get; set; } = + new Dictionary(); - if (!operationsByTag.ContainsKey(tag)) - { - operationsByTag[tag] = new List(); - } + private List? _tagDocs; - operationsByTag[tag].Add(new OperationInfo - { - OperationId = op.OperationId ?? $"{operation.Key}_{path.Key}", - Method = operation.Key.ToString().ToUpperInvariant(), - Path = path.Key, - Summary = op.Summary, - Description = op.Description, - Parameters = op.Parameters?.ToList() ?? new List(), - HasRequestBody = op.RequestBody != null - }); - } - } + /// + /// Operations grouped by tag, with usage examples, for README.md and docs/<tag>.md. + /// + public List TagDocs => _tagDocs ??= new DocsBuilder(Document, InlineSchemas).BuildTags(); - return operationsByTag; - } + /// + /// The operation the README shows as an example: the first GET, as the simplest to try. + /// + public OperationDoc? ExampleOperation => + TagDocs.SelectMany(t => t.Operations).FirstOrDefault(op => op.Method == "GET") + ?? TagDocs.SelectMany(t => t.Operations).FirstOrDefault(); /// - /// Helper class for operation information in templates. + /// The generated model classes, for README.md. /// - public class OperationInfo - { - public string OperationId { get; set; } = string.Empty; - public string Method { get; set; } = string.Empty; - public string Path { get; set; } = string.Empty; - public string? Summary { get; set; } - public string? Description { get; set; } - public List Parameters { get; set; } = new(); - public bool HasRequestBody { get; set; } - } + public List Models => new DocsBuilder(Document, InlineSchemas).BuildModels(); /// /// Helper class for security scheme information in templates. diff --git a/src/OpenAPIDyalog/OpenAPIDyalog.csproj b/src/OpenAPIDyalog/OpenAPIDyalog.csproj index 4df6983..b366636 100644 --- a/src/OpenAPIDyalog/OpenAPIDyalog.csproj +++ b/src/OpenAPIDyalog/OpenAPIDyalog.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/src/OpenAPIDyalog/Services/ArtifactGeneratorService.cs b/src/OpenAPIDyalog/Services/ArtifactGeneratorService.cs index aff8278..1c175e2 100644 --- a/src/OpenAPIDyalog/Services/ArtifactGeneratorService.cs +++ b/src/OpenAPIDyalog/Services/ArtifactGeneratorService.cs @@ -127,7 +127,8 @@ public async Task GenerateClientAsync(OpenApiDocument document, string outputDir context.CustomProperties["class_name"] = GeneratorConstants.DefaultClientClass; context.CustomProperties["tags"] = context.GetAllTags() - .Select(tag => StringHelpers.ToValidAplName(tag.ToCamelCase())) + .Select(OperationNaming.TagName) + .Distinct() .ToList(); var output = await _templateService.RenderAsync(template, context); @@ -143,12 +144,19 @@ public async Task GenerateClientAsync(OpenApiDocument document, string outputDir /// Generates the README.md file. /// /// Re-thrown after logging if generation fails. - public async Task GenerateReadmeAsync(OpenApiDocument document, string outputDirectory) + public async Task GenerateReadmeAsync( + OpenApiDocument document, string outputDirectory, + IReadOnlyDictionary? inlineSchemas = null) { try { var template = await _templateService.LoadTemplateAsync(GeneratorConstants.ReadmeTemplate); - var context = new ApiTemplateContext { Document = document, GeneratedAt = DateTime.UtcNow }; + var context = new ApiTemplateContext + { + Document = document, + GeneratedAt = DateTime.UtcNow, + InlineSchemas = inlineSchemas ?? new Dictionary() + }; context.CustomProperties["class_name"] = GeneratorConstants.DefaultClientClass; var output = await _templateService.RenderAsync(template, context); @@ -163,4 +171,32 @@ public async Task GenerateReadmeAsync(OpenApiDocument document, string outputDir throw; } } + + /// + /// Generates docs/<tag>.md for each tag, named by the tag's APL name (as used for + /// client.<tag>), which is always a safe file name. + /// + public async Task GenerateTagDocsAsync( + OpenApiDocument document, string outputDirectory, + IReadOnlyDictionary? inlineSchemas = null) + { + var template = await _templateService.LoadTemplateAsync(GeneratorConstants.TagDocTemplate); + var context = new ApiTemplateContext + { + Document = document, + GeneratedAt = DateTime.UtcNow, + InlineSchemas = inlineSchemas ?? new Dictionary() + }; + + foreach (var tag in context.TagDocs) + { + context.CustomProperties["tag"] = tag; + + var output = await _templateService.RenderAsync(template, context); + var path = Path.Combine(outputDirectory, tag.DocPath); + + await _templateService.SaveOutputAsync(output, path); + _logger.LogInformation("Generated: {DocPath}", tag.DocPath); + } + } } diff --git a/src/OpenAPIDyalog/Services/CodeGeneratorService.cs b/src/OpenAPIDyalog/Services/CodeGeneratorService.cs index 3bc22dd..a50a096 100644 --- a/src/OpenAPIDyalog/Services/CodeGeneratorService.cs +++ b/src/OpenAPIDyalog/Services/CodeGeneratorService.cs @@ -3,6 +3,7 @@ using OpenAPIDyalog.Constants; using OpenAPIDyalog.Models; using OpenAPIDyalog.Services.Interfaces; +using OpenAPIDyalog.Utils; namespace OpenAPIDyalog.Services; @@ -33,6 +34,10 @@ public CodeGeneratorService( /// public async Task GenerateAsync(OpenApiDocument document, GeneratorOptions options) { + // Names that would make one generated file overwrite another stop generation. + OperationNaming.CheckFunctionNames(document); + SchemaHelpers.CheckModelClassNames(document); + await _artifacts.GenerateUtilsAsync(document, options.OutputDirectory); await _artifacts.GenerateVersionAsync(document, options.OutputDirectory); await _artifacts.CopyHttpCommandAsync(options.OutputDirectory); @@ -42,10 +47,11 @@ public async Task GenerateAsync(OpenApiDocument document, GeneratorOptions optio document, options.OutputDirectory, options.Namespace); await _artifacts.GenerateClientAsync(document, options.OutputDirectory, options.Namespace); - await _artifacts.GenerateReadmeAsync(document, options.OutputDirectory); + await _artifacts.GenerateReadmeAsync(document, options.OutputDirectory, inlineSchemas); + await _artifacts.GenerateTagDocsAsync(document, options.OutputDirectory, inlineSchemas); - // Model generation is not yet implemented; the following calls are no-ops: - await _models.GenerateInlineSchemaModelsAsync(inlineSchemas, options.OutputDirectory); + // Model generation + await _models.GenerateInlineSchemaModelsAsync(inlineSchemas, document, options.OutputDirectory); await _models.GenerateComponentModelsAsync(document, options.OutputDirectory); } diff --git a/src/OpenAPIDyalog/Services/EndpointGeneratorService.cs b/src/OpenAPIDyalog/Services/EndpointGeneratorService.cs index b7e176f..3e86406 100644 --- a/src/OpenAPIDyalog/Services/EndpointGeneratorService.cs +++ b/src/OpenAPIDyalog/Services/EndpointGeneratorService.cs @@ -40,20 +40,23 @@ public async Task> GenerateEndpoints var inlineSchemas = new Dictionary(); var operationsByTag = GroupOperationsByTag(document); + // Inline models share the models directory with component models, so must not take their names. + var componentModels = SchemaHelpers.ModelComponents(document) + .Select(kvp => SchemaHelpers.ClassNameOf(kvp.Key)) + .ToHashSet(); + foreach (var tagGroup in operationsByTag) { - var tagDirName = StringHelpers.ToValidAplName(tagGroup.Key.ToCamelCase()); + var tagDirName = OperationNaming.TagName(tagGroup.Key); var tagDir = Path.Combine(tagsDir, tagDirName); Directory.CreateDirectory(tagDir); - foreach (var (path, method, operation) in tagGroup.Value) + foreach (var (path, method, operation, pathItem) in tagGroup.Value) { - var rawId = operation.OperationId - ?? $"{method}_{path.Replace("/", "_").Replace("{", "").Replace("}", "")}"; - var operationId = StringHelpers.ToValidAplName(rawId.Replace("/", "_").ToPascalCase()); + var operationId = OperationNaming.FunctionName(operation.OperationId, method, path); - var context = BuildOperationContext(path, method, operation, document, operationId); - ResolveRequestBody(operation, operationId, context, inlineSchemas); + var context = BuildOperationContext(path, method, operation, pathItem, document, operationId); + ResolveRequestBody(operation, operationId, document, context, inlineSchemas, componentModels); var output = await _templateService.RenderAsync(template, context); var outputPath = Path.Combine(tagDir, $"{operationId}.aplf"); @@ -71,10 +74,10 @@ public async Task> GenerateEndpoints /// Groups all operations from the document by their first tag. /// Operations without a tag fall into "default". /// - internal static Dictionary> + internal static Dictionary> GroupOperationsByTag(OpenApiDocument document) { - var result = new Dictionary>(); + var result = new Dictionary>(); foreach (var path in document.Paths) { @@ -82,12 +85,12 @@ public async Task> GenerateEndpoints foreach (var operation in path.Value.Operations) { - var tag = operation.Value.Tags?.FirstOrDefault()?.Name ?? GeneratorConstants.DefaultTagName; + var tag = OperationNaming.TagOf(operation.Value); if (!result.ContainsKey(tag)) - result[tag] = new List<(string, string, OpenApiOperation)>(); + result[tag] = new List<(string, string, OpenApiOperation, IOpenApiPathItem)>(); - result[tag].Add((path.Key, operation.Key.ToString().ToLowerInvariant(), operation.Value)); + result[tag].Add((path.Key, operation.Key.ToString().ToLowerInvariant(), operation.Value, path.Value)); } } @@ -95,7 +98,7 @@ public async Task> GenerateEndpoints } private static OperationTemplateContext BuildOperationContext( - string path, string method, OpenApiOperation operation, + string path, string method, OpenApiOperation operation, IOpenApiPathItem pathItem, OpenApiDocument document, string operationId) { // Operation-level security overrides document-level. @@ -113,7 +116,7 @@ private static OperationTemplateContext BuildOperationContext( Summary = operation.Summary, Description = operation.Description, Tags = operation.Tags?.Select(t => t.Name).Where(n => n != null).Cast().ToList() ?? new(), - Parameters = operation.Parameters?.ToList() ?? new(), + Parameters = OperationNaming.MergeParameters(path, pathItem, operation), RequestBody = operation.RequestBody, Responses = operation.Responses?.ToDictionary(r => r.Key, r => r.Value) ?? new(), Deprecated = operation.Deprecated, @@ -124,8 +127,10 @@ private static OperationTemplateContext BuildOperationContext( private static void ResolveRequestBody( OpenApiOperation operation, string operationId, + OpenApiDocument document, OperationTemplateContext context, - Dictionary inlineSchemas) + Dictionary inlineSchemas, + IReadOnlySet componentModels) { if (operation.RequestBody?.Content == null) return; @@ -140,7 +145,9 @@ private static void ResolveRequestBody( case GeneratorConstants.ContentTypeJson: context.RequestContentType = contentType; if (schema != null) - ResolveJsonBodyType(schema, operationId, context, inlineSchemas); + ResolveJsonBodyType(schema, operationId, document, context, inlineSchemas, componentModels); + else + context.RequestBodyArgName = OperationNaming.UntypedJsonBodyArgName; break; case GeneratorConstants.ContentTypeOctetStream: @@ -166,34 +173,33 @@ private static void ResolveRequestBody( private static void ResolveJsonBodyType( IOpenApiSchema schema, string operationId, + OpenApiDocument document, OperationTemplateContext context, - Dictionary inlineSchemas) + Dictionary inlineSchemas, + IReadOnlySet componentModels) { - if (schema is OpenApiSchemaReference reference) - { - var id = reference.Reference.Id; - if (!string.IsNullOrEmpty(id)) - context.RequestJsonBodyType = StringHelpers.ToValidAplName(id.ToCamelCase()); - } - else if (schema.Type == JsonSchemaType.Array && schema.Items is OpenApiSchemaReference itemsRef) + var body = OperationNaming.DescribeJsonBody(schema, operationId, document); + if (body == null) { - var id = itemsRef.Reference.Id; - if (!string.IsNullOrEmpty(id)) - context.RequestJsonBodyType = StringHelpers.ToValidAplName(id.ToCamelCase()); + context.RequestBodyArgName = OperationNaming.UntypedJsonBodyArgName; + return; } - else if (schema.Type == JsonSchemaType.Object && schema.Properties != null) - { - var modelName = GenerateSyntheticModelName(operationId, "Request", inlineSchemas); - inlineSchemas[modelName] = schema; - context.RequestJsonBodyType = StringHelpers.ToValidAplName(modelName.ToPascalCase()); - } - else if (schema.Type == JsonSchemaType.Array && - schema.Items?.Type == JsonSchemaType.Object && - schema.Items.Properties != null) + + context.RequestBodyArgName = body.ArgName; + context.RequestBodyIsArray = body.IsArray; + context.RequestJsonBodyType = body.ModelName; + + // Inline object schemas get a synthesised model class. Its name may already be taken, by a + // component model or by an operation with the same function name in another tag, so number + // any repeats. The docs look the chosen name up from the returned inline schemas. + if (body.InlineSchema != null) { - var modelName = GenerateSyntheticModelName(operationId, "RequestItem", inlineSchemas); - inlineSchemas[modelName] = schema.Items; - context.RequestJsonBodyType = StringHelpers.ToValidAplName(modelName.ToPascalCase()); + var modelName = body.ModelName!; + var counter = 2; + while (inlineSchemas.ContainsKey(modelName) || componentModels.Contains(modelName)) + modelName = $"{body.ModelName}{counter++}"; + inlineSchemas[modelName] = body.InlineSchema; + context.RequestJsonBodyType = modelName; } } @@ -231,20 +237,4 @@ private static List ResolveFormFields(IOpenApiSchema schema, IOpenApi return fields; } - - private static string GenerateSyntheticModelName( - string operationId, string suffix, Dictionary inlineSchemas) - { - var baseName = $"{operationId.ToPascalCase()}{suffix}"; - var modelName = baseName; - var counter = 2; - - while (inlineSchemas.ContainsKey(modelName)) - { - modelName = $"{baseName}{counter}"; - counter++; - } - - return modelName; - } } diff --git a/src/OpenAPIDyalog/Services/ModelGeneratorService.cs b/src/OpenAPIDyalog/Services/ModelGeneratorService.cs index 76232d0..90f1cad 100644 --- a/src/OpenAPIDyalog/Services/ModelGeneratorService.cs +++ b/src/OpenAPIDyalog/Services/ModelGeneratorService.cs @@ -1,99 +1,282 @@ +using System.Text.Json.Nodes; using CaseConverter; using Microsoft.Extensions.Logging; using Microsoft.OpenApi; +using OpenAPIDyalog.Constants; using OpenAPIDyalog.Models; +using OpenAPIDyalog.Services.Interfaces; using OpenAPIDyalog.Utils; namespace OpenAPIDyalog.Services; /// -/// Generates APL model class files from OpenAPI schemas. +/// Generates APL model class files from OpenAPI component and inline schemas. /// -/// -/// STATUS: Not yet implemented. Both public methods are intentional no-ops. -/// The private helpers below are reference implementations preserved for future use. -/// public class ModelGeneratorService { + private readonly ITemplateService _templateService; private readonly ILogger _logger; - public ModelGeneratorService(ILogger logger) + public ModelGeneratorService(ITemplateService templateService, ILogger logger) { - _logger = logger; + _templateService = templateService; + _logger = logger; } /// - /// Generates model files from OpenAPI component schemas. - /// STATUS: Not yet implemented — intentional no-op. + /// Generates a model file for each object schema in document.Components.Schemas. Array and + /// primitive schemas get no class: wherever they are used, their values are used directly. + /// Call first, as clashing names overwrite each other. /// - public Task GenerateComponentModelsAsync(OpenApiDocument document, string outputDirectory) + public async Task GenerateComponentModelsAsync(OpenApiDocument document, string outputDirectory) { - var count = document.Components?.Schemas?.Count ?? 0; - _logger.LogDebug("Component model generation skipped (not yet implemented). {Count} schema(s) available.", count); - return Task.CompletedTask; + var schemas = SchemaHelpers.ModelComponents(document).ToList(); + if (schemas.Count == 0) + { + _logger.LogDebug("No component object schemas found."); + return; + } + + _logger.LogInformation("Generating {Count} component model(s)...", schemas.Count); + + foreach (var (name, schema) in schemas) + { + var context = CreateModelContext(name, schema, document); + await GenerateModelAsync(context, outputDirectory); + } } /// - /// Generates model files from inline request body schemas discovered during endpoint generation. - /// STATUS: Not yet implemented — intentional no-op. + /// Generates model files for inline schemas discovered during endpoint generation. /// - public Task GenerateInlineSchemaModelsAsync( - IReadOnlyDictionary inlineSchemas, string outputDirectory) + public async Task GenerateInlineSchemaModelsAsync( + IReadOnlyDictionary inlineSchemas, OpenApiDocument document, string outputDirectory) { - _logger.LogDebug("Inline schema model generation skipped (not yet implemented). {Count} schema(s) available.", inlineSchemas.Count); - return Task.CompletedTask; + if (inlineSchemas.Count == 0) return; + + _logger.LogInformation("Generating {Count} inline schema model(s)...", inlineSchemas.Count); + + foreach (var (name, schema) in inlineSchemas) + { + // The document resolves any allOf references to component schemas. + var context = CreateModelContext(name, schema, document); + await GenerateModelAsync(context, outputDirectory); + } } - // ── Reference implementation (no callers yet) ──────────────────────────── - private static ModelTemplateContext CreateModelContext(string schemaName, IOpenApiSchema schema, string? sourceInfo = null) + /// + /// Names the model template defines in a class: methods, their locals, and the values namespace. + /// + private static readonly HashSet ReservedMemberNames = + [ + "make0", "make1", "FormatNS", "FromResponse", "Get", "Set", "⍙v", + "args", "missing", "build", "asNS", "vec", "inst", "ns", "r", + ]; + + // ── Core helpers ──────────────────────────────────────────────────────────── + + private async Task GenerateModelAsync(ModelTemplateContext context, string outputDirectory) + { + var template = await _templateService.LoadTemplateAsync(GeneratorConstants.ModelTemplate); + var rendered = await _templateService.RenderAsync(template, context); + + var outputPath = Path.Combine( + outputDirectory, + GeneratorConstants.AplSourceDir, + GeneratorConstants.ModelsSubDir, + $"{context.ClassName}.aplc"); + + await _templateService.SaveOutputAsync(rendered, outputPath); + _logger.LogDebug("Generated model: {ClassName}", context.ClassName); + } + + private static ModelTemplateContext CreateModelContext( + string schemaName, + IOpenApiSchema schema, + OpenApiDocument? document, + string? sourceInfo = null) { var context = new ModelTemplateContext { - ClassName = StringHelpers.ToValidAplName(schemaName.ToPascalCase()), + ClassName = SchemaHelpers.ClassNameOf(schemaName), Description = schema.Description ?? sourceInfo }; - if (schema.Properties == null) return context; + // Map type: no named properties, and any keys allowed (a free-form object). + if (SchemaHelpers.IsMap(schema)) + { + context.IsMapType = true; + context.MapValueType = schema.AdditionalProperties != null + ? SchemaTypeMapper.MapSchemaTypeToAplType(schema.AdditionalProperties) + : null; + return context; + } + + context.AllowsExtraProperties = schema.AdditionalPropertiesAllowed; - foreach (var property in schema.Properties) + // The schema's own properties, then those inherited through allOf (inheritance / extension pattern). + foreach (var (key, propSchema, required) in SchemaHelpers.Properties(schema, document)) + AddProperty(context, key, propSchema, required, document); + + // Property names that differ only in punctuation (foo-bar, foo_bar) give the same readable + // enum constants name; fall back to the mangled property name, which is unique. + foreach (var clash in context.EnumProperties.GroupBy(p => p.EnumFieldName).Where(g => g.Count() > 1)) + foreach (var prop in clash) + prop.EnumFieldName = prop.DyalogName; + + // A property is a member of the class, so must not take the name of one the template defines + // (its methods, their local names, the values namespace or an enum constants namespace). + var taken = ReservedMemberNames + .Concat(context.EnumProperties.Select(p => "Enum" + p.EnumFieldName)) + .ToHashSet(); + foreach (var prop in context.Properties) + taken.Add(prop.DyalogName); + foreach (var prop in context.Properties) { - var propName = StringHelpers.ToValidAplName(property.Key); - var propSchema = property.Value; + prop.MemberName = prop.DyalogName; + if (!ReservedMemberNames.Contains(prop.DyalogName) + && !context.EnumProperties.Any(p => "Enum" + p.EnumFieldName == prop.DyalogName)) + continue; + do prop.MemberName += "_"; while (!taken.Add(prop.MemberName)); + } - var modelProp = new ModelProperty - { - ApiName = propName, - DyalogName = propName.ToCamelCase(), - Type = SchemaTypeMapper.MapSchemaTypeToAplType(propSchema), - IsRequired = schema.Required?.Contains(propName) ?? false, - Description = propSchema.Description, - IsArray = propSchema.Type == JsonSchemaType.Array - }; - - if (propSchema is OpenApiSchemaReference schemaRef) + return context; + } + + private static void AddProperty( + ModelTemplateContext context, + string rawKey, + IOpenApiSchema propSchema, + bool isRequired, + OpenApiDocument? document) + { + var dyalogName = StringHelpers.ToValidAplName(rawKey); + + var prop = new ModelProperty + { + ApiName = rawKey, + DyalogName = dyalogName, + Type = SchemaTypeMapper.MapSchemaTypeToAplType(propSchema), + IsRequired = isRequired, + IsNullable = SchemaTypeMapper.IsNullable(propSchema), + IsReadOnly = propSchema.ReadOnly, + IsWriteOnly = propSchema.WriteOnly, + Format = propSchema.Format, + Description = propSchema.Description, + IsArray = OperationNaming.IsType(propSchema, JsonSchemaType.Array), + }; + prop.IsStringArray = prop.IsArray + && propSchema.Items != null && OperationNaming.IsType(propSchema.Items, JsonSchemaType.String); + + // Default value — render as string for the comment. + if (propSchema.Default is JsonNode defaultNode && !SchemaHelpers.IsJsonNull(defaultNode)) + prop.DefaultValue = RenderDefaultValue(defaultNode); + + // Enum values. + if (propSchema.Enum?.Count > 0) + { + prop.EnumValues = propSchema.Enum + .Select(ToEnumValue) + .Where(v => v != null) + .Cast() + .ToList(); + + // JSON null has no constant, but a nullable enum accepts it. + prop.EnumAllowsNull = prop.IsNullable || propSchema.Enum.Any(SchemaHelpers.IsJsonNull); + + // Values whose readable names collide (e.g. "in-stock" and "in_stock") fall back to + // the mangled value itself, which is unique. + foreach (var group in prop.EnumValues.GroupBy(v => v.AplName).Where(g => g.Count() > 1)) + foreach (var v in group) + v.AplName = StringHelpers.ToValidAplName(v.ApiValue); + + // Values that are the same text but different JSON types ("1" and 1) still clash: number them. + foreach (var group in prop.EnumValues.GroupBy(v => v.AplName).Where(g => g.Count() > 1)) + foreach (var (v, i) in group.Skip(1).Select((v, i) => (v, i))) + v.AplName += $"_{i + 2}"; + } + + // A $ref to a component model, or an array of them, holds model instances. A $ref to any + // other component (an array of models, a string, …) is treated as that component's schema. + var schema = propSchema; + if (SchemaHelpers.ReferenceId(propSchema) is { } id) + { + if (SchemaHelpers.IsModelReference(propSchema, document)) { - var id = schemaRef.Reference.Id; - if (!string.IsNullOrEmpty(id)) - { - modelProp.IsReference = true; - modelProp.ReferenceType = id.ToCamelCase(); - } + prop.IsReference = true; + prop.ReferenceType = SchemaHelpers.ClassNameOf(id); + prop.Type = prop.ReferenceType; } - else if (propSchema.Type == JsonSchemaType.Array && - propSchema.Items is OpenApiSchemaReference itemsRef) + else { - var id = itemsRef.Reference.Id; - if (!string.IsNullOrEmpty(id)) - { - modelProp.IsReference = true; - modelProp.ReferenceType = id.ToCamelCase(); - } + schema = SchemaHelpers.Resolve(propSchema, document) ?? propSchema; + prop.IsArray = OperationNaming.IsType(schema, JsonSchemaType.Array); + prop.IsStringArray = prop.IsArray && schema.Items != null + && OperationNaming.IsType(schema.Items, JsonSchemaType.String); + prop.Type = SchemaTypeMapper.MapSchemaTypeToAplType(schema); } + } - context.Properties.Add(modelProp); + if (prop.IsArray && SchemaHelpers.IsModelReference(schema.Items, document)) + { + prop.IsReference = true; + prop.ReferenceType = SchemaHelpers.ClassNameOf(SchemaHelpers.ReferenceId(schema.Items)!); + prop.Type = $"array[{prop.ReferenceType}]"; } - return context; + context.Properties.Add(prop); + } + + /// + /// Renders a JsonNode default value to a human-readable string for comments. + /// + private static string? RenderDefaultValue(JsonNode node) + { + // JsonNode.ToString() on a string value includes surrounding quotes — strip them. + if (node is JsonValue jv) + { + if (jv.TryGetValue(out var s)) return s; + return jv.ToString(); + } + return node.ToString(); + } + + /// + /// Converts an enum value to its API text, a name for its constant, and an APL literal of the + /// right type: a quoted string, a number, or ⊂'true'/⊂'false' (as ⎕JSON represents booleans). + /// Returns null for a null enum value, which has no constant. + /// + internal static EnumValue? ToEnumValue(JsonNode? node) + { + if (node is not JsonValue jv || SchemaHelpers.IsJsonNull(node)) return null; + + string apiValue; + string literal; + if (jv.TryGetValue(out var s)) + { + apiValue = s; + literal = StringHelpers.ToAplString(s); + } + else if (jv.TryGetValue(out var b)) + { + apiValue = b ? "true" : "false"; + literal = $"(⊂'{apiValue}')"; + } + else + { + apiValue = jv.ToJsonString(); + literal = apiValue.Replace('-', '¯'); + } + + // Strings get a readable PascalCase name; numbers and booleans are named by their + // value, so 3 and -3 stay distinct. + var name = s != null ? apiValue.ToPascalCase() : apiValue; + return new EnumValue + { + ApiValue = apiValue, + AplName = StringHelpers.ToValidAplName(name.Length > 0 ? name : apiValue), + AplLiteral = literal + }; } } diff --git a/src/OpenAPIDyalog/Services/OpenApiService.cs b/src/OpenAPIDyalog/Services/OpenApiService.cs index 166cd5d..fc36c8c 100644 --- a/src/OpenAPIDyalog/Services/OpenApiService.cs +++ b/src/OpenAPIDyalog/Services/OpenApiService.cs @@ -49,7 +49,9 @@ public async Task LoadSpecificationAsync(string filePath, bo { Document = document, Diagnostic = diagnostic, - IsSuccess = diagnostic?.Errors.Count == 0 + // With validation disabled, any document the reader produced is usable; + // its diagnostics are reported as warnings instead of failing the run. + IsSuccess = document != null && (disableValidation || (diagnostic?.Errors.Count ?? 0) == 0) }; } catch (Exception ex) diff --git a/src/OpenAPIDyalog/Services/TemplateService.cs b/src/OpenAPIDyalog/Services/TemplateService.cs index 99b5d26..3d6fba3 100644 --- a/src/OpenAPIDyalog/Services/TemplateService.cs +++ b/src/OpenAPIDyalog/Services/TemplateService.cs @@ -1,6 +1,7 @@ using System.Reflection; using CaseConverter; using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; using OpenAPIDyalog.Models; using OpenAPIDyalog.Services.Interfaces; using OpenAPIDyalog.Utils; @@ -156,18 +157,10 @@ private static ScriptObject BuildScriptObject(object context) } scriptObject.Import("comment_lines", new Func(StringHelpers.CommentLines)); - - // NOTE: get_operations_by_tag still requires an is ApiTemplateContext check because - // GetOperationsByTag() is a method on ApiTemplateContext that has no equivalent on - // ITemplateContext. This is a known limitation — the interface cannot express - // document-scoped operation grouping without coupling it to the OpenAPI domain. - scriptObject.Import("get_operations_by_tag", - new Func>>(() => - { - if (context is ApiTemplateContext apiCtx) - return apiCtx.GetOperationsByTag(); - return new Dictionary>(); - })); + scriptObject.Import("apl_name", new Func(n => StringHelpers.ToValidAplName(n ?? string.Empty))); + scriptObject.Import("is_array", new Func(s => s != null && OperationNaming.IsType(s, JsonSchemaType.Array))); + scriptObject.Import("one_line", new Func(StringHelpers.ToSingleLine)); + scriptObject.Import("md_cell", new Func(StringHelpers.ToTableCell)); return scriptObject; } diff --git a/src/OpenAPIDyalog/Templates/APLSource/HttpCommand.aplc b/src/OpenAPIDyalog/Templates/APLSource/HttpCommand.aplc index 25be9c4..f169738 100644 --- a/src/OpenAPIDyalog/Templates/APLSource/HttpCommand.aplc +++ b/src/OpenAPIDyalog/Templates/APLSource/HttpCommand.aplc @@ -7,7 +7,7 @@ ∇ r←Version ⍝ Return the current version :Access public shared - r←'HttpCommand' '5.10.1' '2026-02-11' + r←'HttpCommand' '5.11.1' '2026-05-18' ∇ ⍝ Request-related fields @@ -24,6 +24,11 @@ :field public ChunkSize←0 ⍝ set to size of chunk if using chunked transfer encoding :field public shared HeaderSubstitution←'' ⍝ delimiters to indicate environment/configuration settings be substituted in headers, set to '' to disable +⍝ Server Sent Events (SSE)-related fields + :field public EnableSSE←0 ⍝ do we allow SSE? + :field public ParseSSE←1 ⍝ parse SSE payload into namespace + :field public CloseSSE←0 ⍝ flag to tell listener thread to close + :field public OnSSEfn←'' ⍝ function to call on receipt of SSE message ⍝ Proxy-related fields - only used if connecting through a proxy server :field public ProxyURL←'' ⍝ address of the proxy server @@ -62,6 +67,7 @@ :field Client←'' ⍝ Conga client ID :field ConxProps←'' ⍝ when a client is made, its connection properties are saved so that if either changes, we close the previous client :field origCert←¯1 ⍝ used to check if Cert changed between calls + :field SSEThread←⍬ ⍝ thread that we listen on for SSE ∇ UpdateCommandMethod arg ⍝ keeps Command and its alias Method in sync @@ -112,6 +118,25 @@ {}{0::'' ⋄ LDRC.Names'.'⊣LDRC.Close ⍵}⍣(~0∊⍴Client)⊢Client ∇ + ∇ r OnSSE payload + :Access public + ⍝ r is a ref to the HttpCommand result namespace + ⍝ payload is the SSE event message + :If ParseSSE=1 + payload←parseSSE payload + :If ~0∊⍴payload.id ⋄ r.lastEventId←payload.id ⋄ :EndIf + :EndIf + :If ~0∊⍴OnSSEfn ⍝ user defined handler? + r onSSE payload + :Else ⍝ otherwise just display the event message + :If 9.1=nameClass payload ⍝ namespace? + ⎕←1 ⎕JSON⍠'Compact' 0⊢payload + :Else + ⎕←payload + :EndIf + :EndIf + ∇ + ∇ r←Config;i ⍝ Returns current configuration :Access public @@ -147,6 +172,7 @@ ∇ ∇ {r}←setDisplayFormat r;rc;msg;stat;data + :Access public shared ⍝ set the display format for the namespace result for most HttpCommand commands :If 9.1=nameClass r rc←'rc: ',⍕r.rc @@ -210,7 +236,7 @@ :If 0=⎕NC'requestOnly' ⋄ requestOnly←¯1 ⋄ :EndIf :If isSimpleChar args ⍝ simple character vector args? - :AndIf (args≡'localhost')≥∧/args∊over lc ⎕A ⋄ args←'GET'args ⋄ :EndIf ⍝ localhost or only alphabetics? + :AndIf (args≡'localhost')≥∧/args∊over lc ⎕A ⋄ args←'GET'args ⋄ :EndIf ⍝ localhost or not only alphabetics? →∆EXIT⍴⍨9.1=nameClass cmd←r←requestOnly New args :If 0∊⍴cmd.Command ⋄ cmd.Command←(1+0∊⍴cmd.Params)⊃'POST' 'GET' ⋄ :EndIf @@ -425,7 +451,7 @@ ∆FAIL:(rc secureParams)←¯1 msg ⍝ failure ∇ - ∇ {r}←certs HttpCmd args;url;parms;hdrs;urlparms;p;b;secure;port;host;path;auth;req;err;done;data;datalen;rc;donetime;ind;len;obj;evt;dat;z;msg;timedOut;certfile;keyfile;simpleChar;defaultPort;cookies;domain;t;replace;outFile;toFile;startSize;options;congaPath;progress;starttime;outTn;secureParams;ct;forceClose;headers;cmd;file;protocol;conx;proxied;proxy;cert;noCT;simpleParms;noContentLength;connectionClose;tmpFile;tmpTn;redirected;encoding;compType;isutf8;boundary + ∇ {r}←certs HttpCmd args;url;parms;hdrs;urlparms;p;b;secure;port;host;path;auth;req;err;done;data;datalen;sse;rc;donetime;ind;len;obj;evt;dat;z;msg;timedOut;certfile;keyfile;simpleChar;defaultPort;cookies;domain;t;replace;outFile;toFile;startSize;options;congaPath;progress;starttime;outTn;secureParams;ct;forceClose;headers;cmd;file;protocol;conx;proxied;proxy;cert;noCT;simpleParms;noContentLength;connectionClose;tmpFile;tmpTn;redirected;encoding;compType;isutf8;boundary ⍝ issue an HTTP command ⍝ certs - X509Cert|(PublicCertFile PrivateKeyFile) SSLValidation Priority PublicCertFile PrivateKeyFile ⍝ args - [1] HTTP method @@ -442,7 +468,7 @@ :If 0∊⍴cmd ⋄ cmd←'GET' ⋄ :EndIf r←Result - toFile←redirected←outTn←tmpTn←0 ⍝ initial settings + sse←toFile←redirected←outTn←tmpTn←0 ⍝ initial settings tmpFile←'' url←,url @@ -455,6 +481,17 @@ →∆END↓⍨0∊⍴r.msg←'Cookies are not character'/⍨(0∊⍴cookies)⍱1↑isChar cookies →∆END↓⍨0∊⍴r.msg←'Headers are not character'/⍨(0∊⍴headers)⍱1↑isChar headers + :If EnableSSE + :If ~0∊⍴OnSSEfn + →∆END↓⍨0∊⍴r.msg←'OnSSEfn is not the name of a function'/⍨3≠{0::0 ⋄ 40 ##.⎕ATX ⍵}OnSSEfn + →∆END↓⍨0∊⍴r.msg←('OnSSEfn (',OnSSEfn,') is not a dyadic function')/⍨2≠|11 ##.⎕ATX OnSSEfn + onSSE←##⍎OnSSEfn + :EndIf + :Trap Debug↓0 + r.⎕FX'Update' ':Implements trigger rc, msg, HttpStatus, HttpMsg' '{0:: ⋄ ref.setDisplayFormat ⍵} ⎕THIS' + :EndTrap + :EndIf + :If ~RequestOnly ⍝ don't bother initializing Conga if only returning request →∆END↓⍨0∊⍴(Initialize r).msg :EndIf @@ -752,7 +789,7 @@ ∆LISTEN: forceClose←~KeepAlive - (timedOut done data progress noContentLength connectionClose)←0 0 ⍬ 0 0 0 + (timedOut done data progress noContentLength connectionClose sse)←0 0 ⍬ 0 0 0 0 :Trap 1000 ⍝ in case break is pressed while listening :While ~done @@ -771,6 +808,22 @@ noContentLength←datalen=¯1 done←(cmd≡'HEAD')∨(0=datalen)∨204=r.HttpStatus →∆END⍴⍨forceClose←r CheckPayloadSize datalen ⍝ we have a payload size limit + :If (r.GetHeader'Content-Type')(beginsWith ci)'text/event-stream' + :If EnableSSE + :If 0≠tmpTn ⍝ if sending SSE output to file, we don't use the temporary that "normal" requests would write to + ⎕NUNTIE tmpTn + {0:: ⋄ ⎕NDELETE ⍵}tmpFile + tmpTn←0 + :EndIf + sse←1 + r.ref←⎕THIS + r.⎕FX'Close' 'ref.CloseSSE←1' + r.SSEThread←outTn SSEListen&r + →∆END⊣r.(rc msg)←0('SSE listener running on thread ',⍕r.SSEThread) + :Else + →∆END⊣r.(rc msg)←¯1 'Server responded with SSE, but EnableSSE=0' + :EndIf + :EndIf :EndIf :Case 'HTTPBody' →∆END⍴⍨forceClose←r CheckPayloadSize(≢data)+≢dat @@ -811,12 +864,12 @@ r.(rc msg)←0 '' done←1 :Else - rc.msg←'Response payload not completely received' + r.msg←'Response payload not completely received' →∆END :EndIf :Case 'BlockLast' ⍝ BlockLast included for pre-Conga v3.4 compatibility for RFC7230 (Sec 3.3.3 item 7) →∆END⍴⍨forceClose←r CheckPayloadSize(≢data)+≢dat - :If toFileredirected →∆END⍴⍨forceClose←r CheckPayloadSize(⎕NSIZE tmpTn)+≢dat dat ⎕NAPPEND tmpTn ⎕NUNTIE ⍬ @@ -868,7 +921,7 @@ compType←¯2 ¯3 0['deflate' 'gzip'⍳⊂encoding] - :If toFile≤redirected + :If toFile⍱redirected :Trap Debug↓0 ⍝ If any errors occur, abandon conversion :If ~0∊⍴data :If ~0∊⍴encoding @@ -908,7 +961,7 @@ r.Data←data :EndIf - :Else ⍝ toFile and not redirected + :ElseIf toFile>redirected ⍝ toFile and not redirected :If ~0∊⍴encoding ⍝ content-encoding header? :If 0≠compType :If 0≠z←compType UnzipFile tmpTn @@ -955,7 +1008,7 @@ :EndIf r.rc←1⊃rc ⍝ set the return code to the Conga return code ∆END: - ⎕NUNTIE tmpTn,outTn + ⎕NUNTIE tmpTn,sse↓outTn {0:: ⋄ ⎕NDELETE ⍵}tmpFile Client←{0::'' ⋄ KeepAlive>forceClose:⍵ ⋄ ''⊣LDRC.Close ⍵}Client ∆EXIT: @@ -974,6 +1027,54 @@ r[⍸~mask]←2⌽∊NL∘,¨hlens,¨⊂NL ⍝ insert chunk information ∇ + ∇ outTn SSEListen r;err;rc;obj;evt;dat + ⍝ Listener for SSEs + ⍝ r is the HttpCommand result namespace + ⍝ outTn is tie number of OutFile if specified + CloseSSE←0 ⍝ set by ∇Close in HttpCommand result namespace + r.lastEventId←'' + :While ~CloseSSE + :Trap Debug↓0 1000 + :If 0=err←1⊃rc←LDRC.Wait Client WaitTime + (err obj evt dat)←4↑rc + :Select evt + :Case 'HTTPChunk' + dat←1⊃dat ⍝ actual message data is in first element + :If 0≠outTn + :If ~CloseSSE←r CheckPayloadSize(⎕NSIZE outTn)+≢dat + dat ⎕NAPPEND outTn + :EndIf + :EndIf + r OnSSE dat + :Case 'Timeout' + :Case 'Error' + r.rc←4⊃rc + r.msg←'Conga error processing your request: ',,⍕r.rc + CloseSSE←1 + :Case 'Closed' + r.msg←'Socket closed by server' + CloseSSE←1 + :Else + r.(rc msg)←¯1('Unhandled Conga event type: ',evt) ⍝ This shouldn't happen + CloseSSE←1 + :EndSelect + :Else + r.(rc msg)←¯1('Conga wait error ',,⍕rc) ⍝ some other error (very unlikely) + CloseSSE←1 + :EndIf + :Case 1002 + CloseSSE←1 + r.msg←'SSE listener on thread ',(⍕⎕TID),' closed due to interrupt' + :Else + r.(rc msg)←¯1('Unexpected ',⊃{⍺,' at ',⍵}/2↑⎕DMX.DM) + CloseSSE←1 + :EndTrap + :EndWhile + ⎕NUNTIE outTn + {}{0::'' ⋄ LDRC.Close ⍵}Client + ⎕TKILL ⎕TID + ∇ + ∇ rc←r CheckPayloadSize size ⍝ checks if payload exceeds MaxPayloadSize rc←0 @@ -989,7 +1090,7 @@ →∆EXIT↓⍨timedOut←⎕AI[3]>donetime ⍝ exit unless donetime hasn't passed →∆EXIT↓⍨Timeout<0 ⍝ if Timeout<0, reset donetime if there's progress →∆EXIT↓⍨0=⊃tmp←LDRC.Tree obj ⍝ look at the current state of the connection - snap←2 2⊃tmp ⍝ second element shoulf contain the state + snap←2 2⊃tmp ⍝ second element should contain the state :If ~0∊⍴snap ⍝ if we have any... snap←(⊂∘⍋⌷⊢)↑(↑2 2⊃tmp)[;1] ⍝ ...progress should be in elements [4 5] :EndIf @@ -998,6 +1099,42 @@ ∆EXIT: ∇ + ∇ r←parseSSE txt;lf;lines;line;name;value;i + ⍝ Parse a single SSE "chunk" into a namespace + ⍝ based on: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + ⍝ txt: character vector - raw event text (one event block) + ⍝ r: namespace with fields: + ⍝ event - event type ('message' if unset) + ⍝ data - payload (vector of vectors) + ⍝ id - event ID (if present, used to set LastEventID) + ⍝ retry - reconnection time in ms (⍬ if unset) + lf←⎕UCS 10 + txt←'\r\n|\r'⎕R lf⍠'Mode' 'D'⊢txt ⍝ Normalize line endings to LF + lines←lf(≠⊆⊢)txt ⍝ Split into non-empty lines + r←⎕NS'' + r.(event data id retry)←'message'⍬''⍬ + :For line :In lines + i←line⍳':' + :Select i + :Case 1 ⍝ comment - do nothing + :Case 1+≢line ⍝ no colon: name-only field + (name value)←line'' + :Else + name←(i-1)↑line ⋄ value←i↓line + value←(' '=⊃value)↓value ⍝ Strip at most one leading space (per spec §9.2.6) + :Select name + :Case 'event' ⋄ r.event←value + :Case 'data' ⋄ r.data,←⊂value + :Case 'id' ⋄ r.id←value + :Case 'retry' + :If (0<≢value)∧∧/value∊⎕D + r.retry←⊃⊃(//)⎕VFI value + :EndIf + :EndSelect + :EndSelect + :EndFor + ∇ + ∇ {r}←type UnzipFile tn;data :Access public shared ⍝ Unzip an output file @@ -1329,7 +1466,7 @@ :For cookie :In new :If 0≠ind←cookies.Name iotaz⊂cookie.Name :If 0∊⍴cookie.Value ⍝ deleted cookie? - cookie←(ind≠⍳≢cookies)/cookies + cookies←(ind≠⍳≢cookies)/cookies :Else cookies[ind]←cookie :EndIf @@ -1605,4 +1742,4 @@ r←¯1('Unexpected ',⊃{⍺,' at ',⍵}/2↑⎕DMX.DM) :EndTrap ∇ -:EndClass \ No newline at end of file +:EndClass diff --git a/src/OpenAPIDyalog/Templates/APLSource/_tags/endpoint.aplf.scriban b/src/OpenAPIDyalog/Templates/APLSource/_tags/endpoint.aplf.scriban index 1e9fbb5..1277c2a 100644 --- a/src/OpenAPIDyalog/Templates/APLSource/_tags/endpoint.aplf.scriban +++ b/src/OpenAPIDyalog/Templates/APLSource/_tags/endpoint.aplf.scriban @@ -1,7 +1,7 @@ -response←{{ operation_id }} argsNs;headerParams;queryParams;body;clientArgs;secSchemes +response←{{ operation_id }} argsNs;headerParams;queryParams;body;hasBody;clientArgs;secSchemes ⍝ {{ method | string.upcase }} {{ path }} {{~ if summary ~}} -⍝ {{ summary }} +{{ comment_lines summary }} {{~ end ~}} {{~ if description ~}} {{ comment_lines description }} @@ -10,13 +10,13 @@ response←{{ operation_id }} argsNs;headerParams;queryParams;body;clientArgs;se ⍝ ⍝ Parameters: {{~ for param in parameters ~}} -⍝ - {{ param.name }} (in: {{ param.in | string.downcase }}) [{{ if param.required }}required{{ else }}optional{{ end }}]{{ if param.schema.type }}: {{ param.schema.type }}{{ end }} +⍝ - {{ apl_name param.name }} (in: {{ param.in | string.downcase }}) [{{ if param.required }}required{{ else }}optional{{ end }}]{{ if param.schema.type }}: {{ param.schema.type }}{{ end }} {{~ end ~}} {{~ end ~}} -{{~ if request_json_body_type ~}} +{{~ if request_content_type == "application/json" && request_body_arg_name ~}} ⍝ ⍝ Request Body: -⍝ - {{ request_content_type }}: {{ request_json_body_type }} schema{{ if request_body.required }} (required){{ end }} +⍝ - {{ request_body_arg_name }} ({{ request_content_type }}){{ if request_json_body_type }}: {{ if request_body_is_array }}array of {{ end }}{{ request_json_body_type }}{{ end }}{{ if request_body.required }} (required){{ end }} {{~ end ~}} {{~ if request_content_type == "application/octet-stream" ~}} ⍝ @@ -52,54 +52,64 @@ response←{{ operation_id }} argsNs;headerParams;queryParams;body;clientArgs;se headerParams←⍬ queryParams←() body←() +hasBody←0 ⍝ whether there is a request body to send: an optional one may be left out +{{~ # Parameters are read from argsNs under their APL names (apl_name), which differ from + # the API name only when that is not a valid APL identifier (e.g. X-Trace → ⍙X⍙45⍙Trace). ~}} {{~ for param in parameters ~}} +{{~ name = apl_name param.name ~}} {{~ if param.in == "Query" ~}} ⍝ Query parameter: {{ param.name }} -:If (2=argsNs.⎕NC'{{ param.name }}') - queryParams.{{ param.name }}←argsNs.{{ param.name }} +:If (2=argsNs.⎕NC'{{ name }}') +{{~ if (is_array param.schema) && !param.explode ~}} + queryParams.{{ name }}←'{{ if param.style == "SpaceDelimited" }} {{ else if param.style == "PipeDelimited" }}|{{ else }},{{ end }}'c.∆.utils.joinArray argsNs.{{ name }} ⍝ explode: false +{{~ else ~}} + queryParams.{{ name }}←argsNs.{{ name }} +{{~ end ~}} :{{ if param.required }}Else - ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ param.name }}') + ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ name }}') :{{ end }}EndIf {{~ end ~}} {{~ if param.in == "Header" ~}} ⍝ Header parameter: {{ param.name }} -:If (2=argsNs.⎕NC'{{ param.name }}') - headerParams,←'{{ param.name }}' argsNs.{{ param.name }} +:If (2=argsNs.⎕NC'{{ name }}') + headerParams,←'{{ param.name }}' argsNs.{{ name }} :{{ if param.required }}Else - ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ param.name }}') + ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ name }}') :{{ end }}EndIf {{~ end ~}} {{~ if param.in == "Path" ~}} ⍝ Path parameter: {{ param.name }} -:If 2≠argsNs.⎕NC'{{ param.name }}' - ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ param.name }}') +:If 2≠argsNs.⎕NC'{{ name }}' + ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ name }}') :EndIf -:If ~c.∆.utils.isValidPathParam argsNs.{{ param.name }} - ⎕SIGNAL⊂('EN' 11)('Message' 'Invalid parameter: {{ param.name }} (must be a character vector or scalar number)') +:If ~c.∆.utils.isValidPathParam argsNs.{{ name }} + ⎕SIGNAL⊂('EN' 11)('Message' 'Invalid parameter: {{ name }} (must be a character vector or scalar number)') :EndIf -:If 2|⎕DR argsNs.{{ param.name }} ⍝ If numeric, convert to string - argsNs.{{ param.name }}←⍕argsNs.{{ param.name }} +:If 2|⎕DR argsNs.{{ name }} ⍝ If numeric, convert to string + argsNs.{{ name }}←c.∆.utils.toText argsNs.{{ name }} :EndIf {{~ end ~}} {{~ end ~}} -{{~ if request_content_type == "application/json" && request_json_body_type ~}} -{{~ # Convert PascalCase to camelCase for parameter name ~}} -{{~ first_char = request_json_body_type | string.slice 0 1 | string.downcase ~}} -{{~ rest_chars = request_json_body_type | string.slice 1 ~}} -{{~ request_body_param_name = first_char + rest_chars ~}} -⍝ Request body (JSON) -:If (0≠argsNs.⎕NC'{{ request_body_param_name }}') - body←argsNs.{{ request_body_param_name }} +{{~ if request_content_type == "application/json" && request_body_arg_name ~}} +{{~ if request_json_body_type ~}} +⍝ Request body (JSON): a namespace or a models.{{ request_json_body_type }} instance{{ if request_body_is_array }}, or a vector of them{{ end }} +{{~ else ~}} +⍝ Request body (JSON), sent as given +{{~ end ~}} +:If (0≠argsNs.⎕NC'{{ request_body_arg_name }}') + body←{{ if request_body_is_array }},{{ end }}c.∆.utils.formatBody argsNs.{{ request_body_arg_name }}{{ if request_body_is_array }} ⍝ a JSON array, even of one item{{ end }} + hasBody←1 :{{ if request_body.required }}Else - ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ request_body_param_name }}') + ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: {{ request_body_arg_name }}') :{{ end }}EndIf {{~ end ~}} {{~ if request_content_type == "application/octet-stream" ~}} ⍝ Request body (octet-stream) :If (0≠argsNs.⎕NC'body') body←argsNs.body + hasBody←1 :{{ if request_body.required }}Else ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: body') :{{ end }}EndIf @@ -115,8 +125,15 @@ body←() {{~ if field.description ~}} {{ comment_lines field.description }} {{~ end ~}} +{{~ if (apl_name field.api_name) == field.api_name ~}} :If (0≠argsNs.⎕NC'{{ field.dyalog_name }}') body.{{ field.api_name }}←argsNs.{{ field.dyalog_name }} + hasBody←1 +{{~ else ~}} +⍝ HttpCommand sends each form field under its APL name, so cannot send this one +:If (0≠argsNs.⎕NC'{{ field.dyalog_name }}') + ⎕SIGNAL⊂('EN' 11)('Message' 'Form field {{ field.api_name }} cannot be sent, as its name is not a valid APL name') +{{~ end ~}} :{{ if field.is_required }}Else ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required form field: {{ field.dyalog_name }}') :{{ end }}EndIf @@ -126,6 +143,7 @@ body←() ⍝ Request body ({{ request_content_type }}) :If (0≠argsNs.⎕NC'data') body←argsNs.data + hasBody←1 :{{ if request_body.required }}Else ⎕SIGNAL⊂('EN' 11)('Message' 'Missing required parameter: data') :{{ end }}EndIf @@ -136,11 +154,16 @@ clientArgs←( ⍝ Basically HttpCommand args in a nutshell Command: '{{ method }}' URL: ({{ dyalog_path }} ⋄ ()) Headers: headerParams +) {{~ if request_body ~}} - Params: body - ContentType: '{{ request_content_type ?? "application/json" }}' +{{~ if request_body.required ~}} +hasBody←1 ⍝ a required body is always sent, even if none of its fields were given +{{~ end ~}} +:If hasBody ⍝ no body, and no Content-Type, if an optional body was left out + clientArgs.Params←body + clientArgs.ContentType←'{{ request_content_type ?? "application/json" }}' +:EndIf {{~ end ~}} -) {{~ if has_security ~}} secSchemes←⍬ @@ -154,7 +177,7 @@ clientArgs←clientArgs c.∆.utils.Authenticate secSchemes queryParams←queryParams ⎕NS 2⊃clientArgs.URL clientArgs.URL←⊃clientArgs.URL :If 0≠⍴queryParams.⎕NL-⍳9 - clientArgs.URL,←'?',c.∆.HttpCommand.UrlEncode (queryParams) + clientArgs.URL,←'?',c.∆.utils.encodeQuery queryParams :EndIf ⍝ Run and handle response diff --git a/src/OpenAPIDyalog/Templates/APLSource/models/model.aplc.scriban b/src/OpenAPIDyalog/Templates/APLSource/models/model.aplc.scriban index c0063b3..768241e 100644 --- a/src/OpenAPIDyalog/Templates/APLSource/models/model.aplc.scriban +++ b/src/OpenAPIDyalog/Templates/APLSource/models/model.aplc.scriban @@ -1,55 +1,194 @@ :Class {{ class_name }} - {{~ if description ~}} - {{ comment_lines description }} +{{~ if description ~}} +{{ comment_lines description }} +{{~ end ~}} +{{~ if is_map_type ~}} + ⍝ Map type — accepts any key/value pairs + {{~ if map_value_type ~}} + ⍝ additionalProperties: {{ map_value_type }} {{~ end ~}} - {{~ for prop in properties ~}} - ⍝ {{ prop.api_name }}: {{ prop.type }}{{ if prop.is_reference }}, REFERENCE{{ end }}{{ if prop.is_required }}, required{{ else }}, optional{{ end }} + + :field _data + + ∇ make0 + :Access public + :Implements constructor + _data←⎕NS '' + ∇ + + ∇ make1 args + :Access public + :Implements constructor + _data←args + ∇ + + ∇ build←FormatNS + :Access public + build←_data + ∇ + + ∇ inst←FromResponse ns + :Access public + inst←⎕THIS + _data←ns + ∇ +{{~ else ~}} +{{~ # The JSON names of the declared properties, as an APL vector of names ~}} +{{~ known = "(" ~}} +{{~ for prop in properties ~}}{{~ known = known + "(⊂,'" + prop.dyalog_name + "')," ~}}{{~ end ~}} +{{~ known = known + "⍬)" ~}} +{{~ for prop in properties ~}} +{{~ prop_comment = prop.api_name + ": " + prop.type ~}} +{{~ if prop.member_name != prop.dyalog_name ~}}{{~ prop_comment = prop.api_name + " (property " + prop.member_name + "): " + prop.type ~}}{{~ end ~}} +{{~ if prop.format ~}}{{~ prop_comment = prop_comment + " (" + prop.format + ")" ~}}{{~ end ~}} +{{~ if prop.is_read_only ~}} +{{~ prop_comment = prop_comment + " [readOnly]" ~}} +{{~ else if prop.is_write_only ~}} +{{~ prop_comment = prop_comment + " [writeOnly]" ~}} +{{~ end ~}} +{{~ if prop.is_nullable ~}}{{~ prop_comment = prop_comment + " [nullable]" ~}}{{~ end ~}} +{{~ if prop.is_required ~}} +{{~ prop_comment = prop_comment + ", required" ~}} +{{~ else ~}} +{{~ prop_comment = prop_comment + ", optional" ~}} +{{~ end ~}} +{{~ if prop.has_enum_values ~}} +{{~ enum_values = "" ~}} +{{~ for v in prop.enum_values ~}} +{{~ enum_values = enum_values + v.api_value + (for.last ? "" : ", ") ~}} +{{~ end ~}} +{{~ prop_comment = prop_comment + ", enum: [" + enum_values + "]" ~}} +{{~ end ~}} +{{~ if prop.default_value ~}}{{~ prop_comment = prop_comment + ", default: " + prop.default_value ~}}{{~ end ~}} +{{~ if prop.is_reference ~}}{{~ prop_comment = prop_comment + ", REFERENCE" ~}}{{~ end ~}} + {{ comment_lines prop_comment | string.replace "\n" "\n " }} +{{~ end ~}} + + :field private ⍙v ⍝ property values, by JSON name (as mangled by ⎕JSON) +{{~ if has_enums ~}} + + ⍝ Enum constants + {{~ for prop in enum_properties ~}} + :field public shared Enum{{ prop.enum_field_name }} {{~ end ~}} +{{~ end ~}} - {{~ for prop in properties ~}} - :field public {{ prop.dyalog_name }} ⍝ {{ prop.type }} +{{~ for prop in properties ~}} + :Property {{ prop.member_name }} + :Access public + ∇ r←Get + r←⍙v ⎕VGET ⊂'{{ prop.dyalog_name }}' (⊂'null') + ∇ + {{~ if !prop.is_read_only ~}} + ∇ Set args + {{~ if prop.has_enum_values ~}} + :If ~(⊂args.NewValue)∊Enum{{ prop.enum_field_name }}.(⍎¨⎕NL ¯2) + {{~ if prop.enum_allows_null ~}} + :AndIf (⊂'null')≢args.NewValue ⍝ JSON null + {{~ end ~}} + ⎕SIGNAL ⊂('EN' 11)('Message' ('{{ prop.member_name }} must be one of: {{ for v in prop.enum_values }}{{ (one_line v.api_value) | string.replace "'" "''" }}{{ if !for.last }}, {{ end }}{{ end }}{{ if prop.enum_allows_null }}, null{{ end }}')) + :EndIf + {{~ end ~}} + ⍙v.{{ prop.dyalog_name }}←args.NewValue + ∇ {{~ end ~}} + :EndProperty - ∇ make1 args +{{~ end ~}} + ∇ make0 :Access public :Implements constructor - ⍝ We will assume correct - ⎕THIS ⎕NS args ⍝ Copy args into ⎕THIS + ⍙v←⎕NS '' ∇ - ∇ build←FormatNS;build + ∇ make1 args;missing;⍙n :Access public - build←() + :Implements constructor + ⍙v←⎕NS '' + {{~ if has_writable_required_fields ~}} + ⍝ Validate required fields (readOnly fields are server-generated, not required from caller) + missing←({{ for f in writable_required_fields }}(⊂,'{{ f }}'){{ if !for.last }},{{ end }}{{ end }})~args.⎕NL ¯2 ¯9 ⍝ values and namespaces + :If 0<≢missing + ⎕SIGNAL ⊂('EN' 11)('Message'('Missing required fields: ',2↓∊(⊂', '),¨missing)) + :EndIf + {{~ end ~}} + ⍝ Set properties through their setters {{~ for prop in properties ~}} - :If (isVarDefined '{{ prop.dyalog_name }}') - {{~ if prop.is_reference ~}} - ⍝REFERENCE - {{~ if prop.is_array ~}} - ⍝ARRAY - ⍝ Check if array is empty - :If 0∊⍴{{ prop.dyalog_name }} - build.{{ prop.api_name }}←⍬ - :Else - build.{{ prop.api_name }}←1∘/{{ prop.dyalog_name }}.FormatNS ⍝ Ensure minimum rank 1 - :EndIf - {{~ else ~}} - build.{{ prop.api_name }}←{{ prop.dyalog_name }}.FormatNS - {{~ end ~}} - {{~ else ~}} + {{~ if !prop.is_read_only ~}} + :If (args.⎕NC '{{ prop.dyalog_name }}')∊2 9 ⋄ {{ prop.member_name }}←args.{{ prop.dyalog_name }} ⋄ :EndIf + {{~ end ~}} + {{~ end ~}} + {{~ if allows_extra_properties ~}} + ⍝ Keep any other properties as given, as the schema allows additional properties + ⍙n←(args.⎕NL ¯2 ¯9)~{{ known }} + :If 0<≢⍙n ⋄ ⍙v ⎕VSET ⍙n{⍺ ⍵}¨args ⎕VGET ⍙n ⋄ :EndIf + {{~ end ~}} + ∇ - {{~ if prop.is_array ~}} - build.{{ prop.api_name }}←1∘/⊆{{ prop.dyalog_name }} + ∇ build←FormatNS;asNS;vec;⍙n + :Access public + ⍝ This instance as a namespace, ready for ⎕JSON. Read-only (server-generated) properties are left out. + asNS←{9.2=⎕NC⊂,'⍵':⍵.FormatNS ⋄ ⍵} ⍝ nested values may be model instances or plain namespaces + vec←{(⊂'null')≡⍵:⍵ ⋄ 1∘/⍵} ⍝ arrays are vectors, even of one item; JSON null stays null + build←⎕NS '' + {{~ for prop in properties ~}} + {{~ if !prop.is_read_only ~}} + :If (⍙v.⎕NC '{{ prop.dyalog_name }}')∊2 9 + {{~ if prop.is_reference && prop.is_array ~}} + build.{{ prop.dyalog_name }}←asNS¨vec ⍙v.{{ prop.dyalog_name }} + {{~ else if prop.is_reference ~}} + build.{{ prop.dyalog_name }}←asNS ⍙v.{{ prop.dyalog_name }} + {{~ else if prop.is_string_array ~}} + build.{{ prop.dyalog_name }}←vec⊆⍙v.{{ prop.dyalog_name }} + {{~ else if prop.is_array ~}} + build.{{ prop.dyalog_name }}←vec ⍙v.{{ prop.dyalog_name }} {{~ else ~}} - build.{{ prop.api_name }}←{{ prop.dyalog_name }} - {{~ end ~}} + build.{{ prop.dyalog_name }}←⍙v.{{ prop.dyalog_name }} {{~ end ~}} :EndIf - + {{~ end ~}} + {{~ end ~}} + {{~ if allows_extra_properties ~}} + ⍙n←(⍙v.⎕NL ¯2 ¯9)~{{ known }} ⍝ additional properties + :If 0<≢⍙n ⋄ build ⎕VSET ⍙n{⍺(asNS ⍵)}¨⍙v ⎕VGET ⍙n ⋄ :EndIf {{~ end ~}} ∇ - ∇ b←isVarDefined varName;_ - :Trap 0 ⋄ _←⎕VGET varName ⋄ b←1 ⋄ :Else ⋄ b←0 ⋄ :EndTrap - ⍝ TODO This is messy. Surely there is a better way + ∇ inst←FromResponse ns;⍙n + :Access public + inst←⎕THIS + {{~ for prop in properties ~}} + {{~ if prop.is_reference && prop.is_array ~}} + :If 2=ns.⎕NC '{{ prop.dyalog_name }}' + :If (⊂'null')≡ns.{{ prop.dyalog_name }} ⍝ JSON null + ⍙v.{{ prop.dyalog_name }}←⊂'null' + :ElseIf 0∊⍴ns.{{ prop.dyalog_name }} + ⍙v.{{ prop.dyalog_name }}←⍬ + :Else + ⍙v.{{ prop.dyalog_name }}←{(⎕NEW (⊃⊃⎕CLASS ⎕THIS).##.{{ prop.reference_type }}).FromResponse ⍵}¨ns.{{ prop.dyalog_name }} + :EndIf + :EndIf + {{~ else if prop.is_reference ~}} + :If 9=ns.⎕NC '{{ prop.dyalog_name }}' ⋄ ⍙v.{{ prop.dyalog_name }}←(⎕NEW (⊃⊃⎕CLASS ⎕THIS).##.{{ prop.reference_type }}).FromResponse ns.{{ prop.dyalog_name }} ⋄ :EndIf + {{~ else ~}} + :If 0 0 || op.has_request_body ~}} -⍝ Prepare arguments -args ← ( -{{~ for param in op.parameters ~}} -{{~ if param.required ~}} - {{ param.name }}: 'value' ⍝ {{ param.schema.type }}{{~ if param.description }} - {{ param.description }}{{~ end ~}} -{{~ end ~}} +{{ scheme.value.description }} {{~ end ~}} -{{~ if op.has_request_body ~}} - ⍝ Add request body parameters as needed - ⍝ TODO (Document what the request body parameters actually are) - ⍝ For now, check the endpoint.aplf file for this, and see what we are expecting. - ⍝ And check the spec. That's a useful thing to always check! {{~ end ~}} -) -⍝ Make the request -response ← client.{{ tag_group.key }}.{{ op.operation_id }} args -{{~ else ~}} -⍝ Make the request (no parameters required) -response ← client.{{ tag_group.key }}.{{ op.operation_id }} () +A credential sent in a header (a bearer token, or an API key in a header) can be given as `env:[NAME]`, +to read it from the environment variable `NAME` when a request is sent. {{~ end ~}} -``` +{{~ if tag_docs.size > 0 ~}} -{{~ if op.parameters.size > 0 }} -**Parameters:** -{{~ for param in op.parameters ~}} -- `{{ param.name }}` ({{ param.in }}){{~ if param.required }} **[required]**{{~ end ~}}{{~ if param.schema.type }}: {{ param.schema.type }}{{~ end ~}}{{~ if param.description }} - {{ param.description }}{{~ end ~}} +## API reference +| Tag | Namespace | Operations | +|-----|-----------|------------| +{{~ for tag in tag_docs ~}} +| [{{ md_cell tag.display_name }}]({{ tag.doc_path }}) | `client.{{ tag.apl_name }}` | {{ tag.operations.size }} | {{~ end ~}} {{~ end ~}} +{{~ if models.size > 0 ~}} ---- - -{{~ end ~}} -{{~ end ~}} - -## Response Handling - -All API methods return an HttpCommand response object with the following structure: - -```apl -response.( - Data ⍝ Parsed JSON response as namespace - HttpStatus ⍝ HTTP status code (200, 404, etc.) - HttpMessage ⍝ HTTP status message - Headers ⍝ Response headers - ⍝ And more, see: https://dyalog.github.io/HttpCommand/latest/result-response/ -) -``` - -**Example:** -```apl -response ← client.health.getHealth () -⎕← 'Status: ', response.HttpStatus -⎕← 'Data: ', response.Data -``` - -{{~ if schemas.size > 0 }} -## Generated Models +## Models -The following data models are available in the `models` namespace: +These classes in the `models` namespace describe the API's data. A JSON request body can be +given as a plain namespace, or as a model instance, `⎕NEW models.{{ models[0].class_name }} args`, +which checks required fields and enum values as it is built. To read a response body into a +model, use `(⎕NEW models.{{ models[0].class_name }}).FromResponse response.Data`. -{{~ for schema in schemas ~}} -- **{{ schema.key }}**{{~ if schema.value.description }} - {{ schema.value.description }}{{~ end ~}} +| Model | Description | +|-------|-------------| +{{~ for model in models ~}} +| `{{ model.class_name }}` | {{ md_cell model.description }} | {{~ end ~}} - -You can use these models as reference for constructing request bodies and understanding response structures. {{~ end ~}} -## Error Handling - -The client returns HttpCommand response objects. Check the `HttpStatus` field to determine success: - -```apl -response ← client.users.getUserById (userId: 'some-id') - -:If response.HttpStatus = 200 - ⍝ Success - user ← response.Data -:Else - ⍝ Error occurred - ⎕← 'Error: ', response.HttpMessage - ⎕← 'Details: ', response.Data -:EndIf -``` - -## Version Information - -You can check the generated client version: +## Version ```apl -⎕← client.∆.Version ⍬ +client.∆.Version ⍬ ``` --- -**Generated by [OpenAPI Dyalog Generator](https://github.com/Dyalog/OpenAPI)** -**Generated on:** {{ generated_at | date.to_string "%Y-%m-%d %H:%M:%S" }} UTC +Generated by the [OpenAPI Dyalog Generator](https://github.com/dyalog-labs/OpenAPI) on {{ generated_at | date.to_string "%Y-%m-%d %H:%M:%S" }} UTC. diff --git a/src/OpenAPIDyalog/Templates/docs/tag.md.scriban b/src/OpenAPIDyalog/Templates/docs/tag.md.scriban new file mode 100644 index 0000000..7c25b22 --- /dev/null +++ b/src/OpenAPIDyalog/Templates/docs/tag.md.scriban @@ -0,0 +1,80 @@ +# {{ tag.display_name }} + +[← {{ title }}](../README.md) +{{~ if tag.description ~}} + +{{ tag.description }} +{{~ end ~}} + +These operations are functions in `client.{{ tag.apl_name }}`. Each takes a namespace of arguments +and returns an [HttpCommand response](https://dyalog.github.io/HttpCommand/latest/result-response/). + +| Function | Method | Path | Summary | +|----------|--------|------|---------| +{{~ for op in tag.operations ~}} +| [`{{ op.function_name }}`](#{{ op.function_name }}) | `{{ op.method }}` | `{{ op.path }}` | {{ if op.deprecated }}**Deprecated.** {{ end }}{{ md_cell op.summary }} | +{{~ end ~}} +{{~ for op in tag.operations ~}} + +--- + + + +## {{ op.function_name }} + +`{{ op.method }} {{ op.path }}` +{{~ if op.deprecated ~}} + +> **Deprecated:** this operation may be removed from the API. +{{~ end ~}} +{{~ if op.summary ~}} + +{{ op.summary }} +{{~ end ~}} +{{~ if op.description && op.description != op.summary ~}} + +{{ op.description }} +{{~ end ~}} + +```apl +{{ op.usage_example }} +``` +{{~ if op.parameters.size > 0 ~}} + +**Parameters** + +| Argument | In | Type | Required | Description | +|----------|----|------|----------|-------------| +{{~ for param in op.parameters ~}} +| `{{ param.apl_name }}`{{ if param.apl_name != param.name }} (`{{ md_cell param.name }}`){{ end }} | {{ param.location }} | `{{ param.type }}` | {{ if param.required }}Yes{{ else }}No{{ end }} | {{ md_cell param.description }} | +{{~ end ~}} +{{~ end ~}} +{{~ if op.request_body ~}} + +**Request body** + +{{~ body = op.request_body ~}} +{{~ if body.content_type == "application/json" ~}} +{{~ if body.model_name ~}} +`{{ body.arg_name }}`{{ if !body.required }} (optional){{ end }}: {{ if body.is_array }}a vector of namespaces or `models.{{ body.model_name }}` instances{{ else }}a namespace or a `models.{{ body.model_name }}` instance{{ end }}, sent as JSON. +{{~ else ~}} +`{{ body.arg_name }}`{{ if !body.required }} (optional){{ end }}: any value that `⎕JSON` can convert, sent as JSON. +{{~ end ~}} +{{~ else if body.content_type == "multipart/form-data" ~}} +One argument per form field, sent as `multipart/form-data`. To upload a file, give its +path prefixed with `@`; see [Multipart form fields](https://dyalog-labs.github.io/OpenAPI/generated-client/operations/#multipart-form-fields). +{{~ else ~}} +`{{ body.arg_name }}`{{ if !body.required }} (optional){{ end }}: a character vector, sent as `{{ body.content_type }}`. +{{~ end ~}} +{{~ end ~}} +{{~ if op.responses.size > 0 ~}} + +**Responses** + +| Status | Description | +|--------|-------------| +{{~ for response in op.responses ~}} +| {{ response.status }} | {{ md_cell response.description }} | +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} diff --git a/src/OpenAPIDyalog/Utils/DocsBuilder.cs b/src/OpenAPIDyalog/Utils/DocsBuilder.cs new file mode 100644 index 0000000..13c8ff6 --- /dev/null +++ b/src/OpenAPIDyalog/Utils/DocsBuilder.cs @@ -0,0 +1,406 @@ +using System.Text; +using System.Text.Json.Nodes; +using CaseConverter; +using Microsoft.OpenApi; +using OpenAPIDyalog.Constants; +using OpenAPIDyalog.Models; + +namespace OpenAPIDyalog.Utils; + +/// +/// Builds the documentation model for README.md and docs/<tag>.md, including a usage +/// example for each operation. Names come from , so the docs +/// describe exactly what the generated functions read. +/// +public sealed class DocsBuilder +{ + // Nested models in usage examples are expanded this many levels deep, then shown as (). + private const int MaxNestingDepth = 3; + // Comments in usage examples are aligned to this column at most. + private const int MaxCommentColumn = 40; + private const int MaxCommentDescription = 60; + private const int MaxEnumValuesShown = 6; + + private readonly OpenApiDocument _document; + private readonly IReadOnlyDictionary _inlineSchemas; + + /// The API. + /// The inline request body schemas found by endpoint generation, keyed by + /// the name of the model generated for each (which may be numbered to avoid a clash). + public DocsBuilder(OpenApiDocument document, IReadOnlyDictionary? inlineSchemas = null) + { + _document = document; + _inlineSchemas = inlineSchemas ?? new Dictionary(); + } + + /// + /// Groups the document's operations by tag, in the order they appear in the spec. + /// Tags that map to the same APL name share one group, as they share one Client field. + /// + public List BuildTags() + { + var groups = new Dictionary(); + + foreach (var (path, pathItem) in _document.Paths ?? new OpenApiPaths()) + { + if (pathItem?.Operations == null) continue; + + foreach (var (method, operation) in pathItem.Operations) + { + var tag = OperationNaming.TagOf(operation); + var aplName = OperationNaming.TagName(tag); + + if (!groups.TryGetValue(aplName, out var group)) + { + group = new TagDoc + { + Name = tag, + AplName = aplName, + DisplayName = ToDisplayName(tag), + Description = _document.Tags?.FirstOrDefault(t => t.Name == tag)?.Description + }; + groups[aplName] = group; + } + + group.Operations.Add(BuildOperation(aplName, path, method.ToString(), operation, pathItem)); + } + } + + return groups.Values.ToList(); + } + + /// + /// Lists the model classes generated from component schemas, then any synthesised for + /// inline request bodies. + /// + public List BuildModels() + { + var models = SchemaHelpers.ModelComponents(_document) + .Select(kvp => new ModelDoc + { + ClassName = SchemaHelpers.ClassNameOf(kvp.Key), + Description = kvp.Value.Description + }) + .ToList(); + + foreach (var name in _inlineSchemas.Keys) + { + models.Add(new ModelDoc + { + ClassName = SchemaHelpers.ClassNameOf(name), + Description = "Inline request body" + }); + } + + return models; + } + + private OperationDoc BuildOperation( + string tagAplName, string path, string method, OpenApiOperation operation, IOpenApiPathItem pathItem) + { + var functionName = OperationNaming.FunctionName(operation.OperationId, method, path); + + // Cookie parameters are not read by the generated functions, so are not documented. + var parameters = OperationNaming.MergeParameters(path, pathItem, operation) + .Where(p => p.In is ParameterLocation.Path or ParameterLocation.Query or ParameterLocation.Header) + .ToList(); + + var doc = new OperationDoc + { + FunctionName = functionName, + OperationId = operation.OperationId, + Method = method.ToUpperInvariant(), + Path = path, + Summary = operation.Summary, + Description = operation.Description, + Deprecated = operation.Deprecated, + Parameters = parameters.Select(p => new ParameterDoc + { + Name = p.Name ?? string.Empty, + AplName = StringHelpers.ToValidAplName(p.Name ?? string.Empty), + Location = p.In.ToString()!.ToLowerInvariant(), + Required = p.Required || p.In == ParameterLocation.Path, + Type = p.Schema != null ? TypeLabel(p.Schema) : "str", + Description = p.Description + }).ToList(), + Responses = (operation.Responses ?? new OpenApiResponses()) + .Select(r => new ResponseDoc { Status = r.Key, Description = r.Value.Description }) + .ToList() + }; + + var example = new List(); + foreach (var (param, info) in parameters.Zip(doc.Parameters)) + { + example.Add(new ExampleLine( + Depth: 1, + Commented: !info.Required, + Code: $"{info.AplName}: {ExampleValue(param.Schema, forParameter: true)}", + Comment: Annotate(info.Type, info.Required, param.Schema, param.Description))); + } + + doc.RequestBody = DescribeRequestBody(operation, functionName, example); + doc.UsageExample = RenderExample(example, $"client.{tagAplName}.{functionName}"); + return doc; + } + + // ── Request bodies ─────────────────────────────────────────────────────── + + private RequestBodyDoc? DescribeRequestBody(OpenApiOperation operation, string functionName, List example) + { + var requestBody = operation.RequestBody; + if (requestBody?.Content == null || requestBody.Content.Count == 0) return null; + + // The generated function uses the first content type listed. + var (contentType, mediaType) = requestBody.Content.First(); + var schema = mediaType?.Schema; + var doc = new RequestBodyDoc { ContentType = contentType, Required = requestBody.Required }; + + switch (contentType) + { + case GeneratorConstants.ContentTypeJson: + { + var body = schema != null ? OperationNaming.DescribeJsonBody(schema, functionName, _document) : null; + doc.ArgName = body?.ArgName ?? OperationNaming.UntypedJsonBodyArgName; + doc.ModelName = body?.InlineSchema != null + ? InlineModelName(body.InlineSchema) ?? body.ModelName + : body?.ModelName; + doc.IsArray = body?.IsArray ?? false; + + var label = doc.ModelName == null + ? (schema != null ? TypeLabel(schema) : "any") + : doc.IsArray ? $"array[{doc.ModelName}]" : doc.ModelName; + // The model's fields, for a body that is a model or an array of them. + IOpenApiSchema? objectSchema = null; + if (doc.ModelName != null) + { + var target = body!.InlineSchema ?? Resolve(schema); + objectSchema = body.InlineSchema == null && doc.IsArray ? Resolve(target?.Items) : target; + } + + if (objectSchema != null) + AddObject(example, 1, false, doc.ArgName, objectSchema, doc.IsArray, + Annotate(label, doc.Required, null, null), new HashSet()); + else + example.Add(new ExampleLine(1, false, $"{doc.ArgName}: {ExampleValue(schema, forParameter: false)}", + Annotate(label, doc.Required, null, null))); + break; + } + + case GeneratorConstants.ContentTypeMultipartForm: + foreach (var (key, prop) in schema?.Properties ?? new Dictionary()) + { + var required = schema!.Required?.Contains(key) ?? false; + var isBinary = prop.Format == "binary"; + // HttpCommand sends a form field under its APL name, so cannot send one whose name is not valid APL + var sendable = StringHelpers.ToValidAplName(key) == key; + example.Add(new ExampleLine(1, !required || !sendable, + $"{StringHelpers.ToValidAplName(key.ToCamelCase())}: {(isBinary ? "'@/path/to/file'" : ExampleValue(prop, forParameter: true))}", + sendable + ? Annotate(isBinary ? "file" : TypeLabel(prop), required, prop, prop.Description) + : $"cannot be sent: {key} is not a valid APL name")); + } + break; + + case GeneratorConstants.ContentTypeOctetStream: + doc.ArgName = "body"; + example.Add(new ExampleLine(1, false, "body: 'raw bytes'", "binary data, as a character vector")); + break; + + default: + doc.ArgName = "data"; + example.Add(new ExampleLine(1, false, "data: '...'", contentType)); + break; + } + + return doc; + } + + /// + /// Adds "name: (" … ")" for an object, with a line per writable property. An array of + /// objects is shown as a one-item vector: "name: ,⊂(" … ")". + /// + private void AddObject( + List lines, int depth, bool commented, string name, IOpenApiSchema schema, + bool isArray, string comment, HashSet visiting) + { + var open = isArray ? ",⊂(" : "("; + var properties = SchemaHelpers.Properties(schema, _document).Where(p => !p.Schema.ReadOnly).ToList(); + + if (properties.Count == 0 || depth > MaxNestingDepth || !visiting.Add(schema)) + { + lines.Add(new ExampleLine(depth, commented, $"{name}: {open})", comment)); + return; + } + + lines.Add(new ExampleLine(depth, commented, $"{name}: {open}", comment)); + foreach (var (key, propSchema, required) in properties) + { + var propName = StringHelpers.ToValidAplName(key); + var propComment = Annotate(TypeLabel(propSchema), required, propSchema, propSchema.Description); + // A union (oneOf/anyOf) is shown as its first alternative. + var shape = Shape(propSchema) ?? propSchema; + var isPropArray = OperationNaming.IsType(shape, JsonSchemaType.Array); + var target = isPropArray ? Shape(shape.Items) : shape; + if (target != null && !(SchemaHelpers.IsObjectModel(target) && !SchemaHelpers.IsMap(target))) + target = null; + + if (target != null) + AddObject(lines, depth + 1, commented || !required, propName, target, isPropArray, propComment, visiting); + else + lines.Add(new ExampleLine(depth + 1, commented || !required, + $"{propName}: {ExampleValue(propSchema, forParameter: false)}", propComment)); + } + lines.Add(new ExampleLine(depth, commented, ")", null)); + + visiting.Remove(schema); + } + + /// + /// The name endpoint generation gave the model for an inline schema. + /// + private string? InlineModelName(IOpenApiSchema schema) => + _inlineSchemas.FirstOrDefault(kvp => ReferenceEquals(kvp.Value, schema)).Key is { } name + ? StringHelpers.ToValidAplName(name.ToPascalCase()) + : null; + + // ── Example rendering ──────────────────────────────────────────────────── + + private sealed record ExampleLine(int Depth, bool Commented, string Code, string? Comment); + + private static string RenderExample(List lines, string call) + { + var sb = new StringBuilder(); + if (lines.Count == 0) + { + sb.Append($"response ← {call} ()"); + return sb.ToString(); + } + + var texts = lines.Select(l => new string(' ', 4 * l.Depth) + (l.Commented ? "⍝ " : "") + l.Code).ToList(); + var column = Math.Min(texts.Zip(lines).Where(t => t.Second.Comment != null).Select(t => t.First.Length).DefaultIfEmpty(0).Max(), + MaxCommentColumn) + 2; + + sb.AppendLine("args ← ("); + foreach (var (text, line) in texts.Zip(lines)) + { + sb.Append(text); + if (line.Comment != null) + sb.Append(new string(' ', Math.Max(2, column - text.Length))).Append("⍝ ").Append(line.Comment); + sb.AppendLine(); + } + sb.AppendLine(")"); + sb.Append($"response ← {call} args"); + return sb.ToString(); + } + + private static string Annotate(string type, bool required, IOpenApiSchema? schema, string? description) + { + var parts = new List { type }; + if (!required) parts.Add("optional"); + + var enumValues = schema?.Enum? + .Select(e => SchemaHelpers.IsJsonNull(e) ? "null" + : e is JsonValue jv && jv.TryGetValue(out var s) ? s : e?.ToJsonString()) + .Where(v => v != null) + .ToList(); + if (enumValues is { Count: > 0 }) + parts.Add("one of: " + string.Join(", ", enumValues.Take(MaxEnumValuesShown)) + + (enumValues.Count > MaxEnumValuesShown ? ", …" : "")); + + var text = string.Join(", ", parts); + var desc = StringHelpers.ToSingleLine(description); + if (desc.Length > MaxCommentDescription) + desc = desc[..(MaxCommentDescription - 1)].TrimEnd() + "…"; + return desc.Length > 0 ? $"{text} — {desc}" : text; + } + + /// + /// A placeholder APL value for a schema: its default or first enum value if it has one. + /// Parameters are sent as text, so a boolean parameter is 'true'; in a JSON body it is ⊂'true'. + /// + private string ExampleValue(IOpenApiSchema? schema, bool forParameter) + { + schema = Shape(schema); + if (schema == null) return "'value'"; + + var sample = (SchemaHelpers.IsJsonNull(schema.Default) ? null : schema.Default as JsonValue) + ?? schema.Enum?.Where(e => !SchemaHelpers.IsJsonNull(e)).OfType().FirstOrDefault(); + + if (OperationNaming.IsType(schema, JsonSchemaType.String)) + return sample != null && sample.TryGetValue(out var s) ? StringHelpers.ToAplString(s) : "'value'"; + + if (OperationNaming.IsType(schema, JsonSchemaType.Integer) || OperationNaming.IsType(schema, JsonSchemaType.Number)) + return sample != null && !sample.TryGetValue(out _) ? sample.ToJsonString().Replace('-', '¯') : "0"; + + if (OperationNaming.IsType(schema, JsonSchemaType.Boolean)) + { + var value = sample != null && sample.TryGetValue(out var b) && !b ? "false" : "true"; + return forParameter ? $"'{value}'" : $"⊂'{value}'"; + } + + if (OperationNaming.IsType(schema, JsonSchemaType.Array)) + { + var items = Shape(schema.Items); + if (items != null && OperationNaming.IsType(items, JsonSchemaType.String)) + return forParameter ? "'value1' 'value2'" : ",⊂'value'"; + if (items != null && (OperationNaming.IsType(items, JsonSchemaType.Integer) || OperationNaming.IsType(items, JsonSchemaType.Number))) + return forParameter ? "1 2" : ",0"; + return "⍬"; + } + + return SchemaHelpers.IsObjectModel(schema) ? "()" : "⍬"; + } + + // ── Schema helpers ─────────────────────────────────────────────────────── + + /// + /// A short type description: a model name for a model, array[T] for an array, else str, int, etc. + /// A $ref to a component that is not a model is described by that component's schema. + /// + private string TypeLabel(IOpenApiSchema schema) + { + if (SchemaHelpers.ReferenceId(schema) is { } id) + return SchemaHelpers.IsModelReference(schema, _document) + ? SchemaHelpers.ClassNameOf(id) + : TypeLabel(Resolve(schema) is { } target && target != schema ? target : new OpenApiSchema()); + + if (OperationNaming.IsType(schema, JsonSchemaType.Array)) + return schema.Items != null ? $"array[{TypeLabel(schema.Items)}]" : "array"; + + // A union: its alternatives, e.g. "str | array[str]" + if (Alternatives(schema) is { Count: > 0 } alternatives) + { + var labels = alternatives.Select(TypeLabel).Distinct().ToList(); + return string.Join(" | ", labels.Take(3)) + (labels.Count > 3 ? " | …" : ""); + } + + return SchemaTypeMapper.MapSchemaTypeToAplType(schema); + } + + private IOpenApiSchema? Resolve(IOpenApiSchema? schema) => SchemaHelpers.Resolve(schema, _document); + + /// + /// The alternatives of a union schema (oneOf or anyOf, with no type of its own), less any that are + /// only null; or null if the schema is not a union. + /// + private List? Alternatives(IOpenApiSchema schema) + { + if (schema.Type != null || schema.Properties is { Count: > 0 }) return null; + var alternatives = schema.OneOf is { Count: > 0 } ? schema.OneOf : schema.AnyOf; + if (alternatives is not { Count: > 0 }) return null; + return alternatives.Where(a => Resolve(a)?.Type != JsonSchemaType.Null).ToList(); + } + + /// + /// The schema an example is built from: resolved, and for a union, its first alternative. + /// + private IOpenApiSchema? Shape(IOpenApiSchema? schema) + { + schema = Resolve(schema); + return schema != null && Alternatives(schema) is [var first, ..] ? Resolve(first) : schema; + } + + private static string ToDisplayName(string tag) => + string.Join(" ", tag.Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries) + .Select(w => char.ToUpper(w[0]) + w[1..])); +} diff --git a/src/OpenAPIDyalog/Utils/OperationNaming.cs b/src/OpenAPIDyalog/Utils/OperationNaming.cs new file mode 100644 index 0000000..cc61665 --- /dev/null +++ b/src/OpenAPIDyalog/Utils/OperationNaming.cs @@ -0,0 +1,172 @@ +using System.Text.RegularExpressions; +using CaseConverter; +using Microsoft.OpenApi; +using OpenAPIDyalog.Constants; + +namespace OpenAPIDyalog.Utils; + +/// +/// Naming rules shared by code generation and documentation generation, so that the +/// generated docs always describe the names the generated code actually uses. +/// +public static class OperationNaming +{ + /// + /// The tag an operation is grouped under: its first tag, or "default" if it has none. + /// + public static string TagOf(OpenApiOperation operation) => + operation.Tags?.FirstOrDefault()?.Name ?? GeneratorConstants.DefaultTagName; + + /// + /// The APL name for a tag: the Client field, the _tags sub-directory and the docs file name. + /// Always a valid APL identifier, so it is also safe to use as a file name. + /// + public static string TagName(string tag) => + StringHelpers.ToValidAplName(tag.ToCamelCase()); + + /// + /// The APL function name for an operation (also its .aplf file name). + /// Falls back to the method and path when the operation has no operationId. + /// + public static string FunctionName(string? operationId, string method, string path) + { + var rawId = operationId + ?? $"{method.ToLowerInvariant()}_{path.Replace("/", "_").Replace("{", "").Replace("}", "")}"; + return StringHelpers.ToValidAplName(rawId.Replace("/", "_").ToPascalCase()); + } + + /// + /// Checks that no two operations map to the same function in the same Client namespace, which + /// would make one overwrite the other (e.g. operationIds listPets and list_pets, or tags + /// "store orders" and "store-orders"). Throws an exception naming the operations if they do. + /// + public static void CheckFunctionNames(OpenApiDocument document) + { + var clashes = (document.Paths ?? new OpenApiPaths()) + .SelectMany(path => (path.Value?.Operations ?? new Dictionary()) + .Select(op => (Path: path.Key, Method: op.Key.ToString().ToUpperInvariant(), Operation: op.Value))) + .GroupBy(op => $"{TagName(TagOf(op.Operation))}.{FunctionName(op.Operation.OperationId, op.Method, op.Path)}") + .Where(g => g.Count() > 1) + .Select(g => $"{string.Join(", ", g.Select(op => $"{op.Method} {op.Path}"))} (all client.{g.Key})") + .ToList(); + + if (clashes.Count > 0) + throw new InvalidOperationException( + "Some operations would generate the same function: " + string.Join("; ", clashes) + + ". Give each a distinct operationId in the specification."); + } + + /// + /// The parameters that apply to an operation: those declared on the path item, overridden + /// by any operation-level parameter with the same name and location. + /// The path parameters are made to match the {placeholders} in the path, as the generated + /// function builds its URL from them: a placeholder that is not declared (a fault in the spec) + /// becomes a required string parameter, and a declared path parameter that is not in the + /// path is dropped. + /// + public static List MergeParameters(string path, IOpenApiPathItem? pathItem, OpenApiOperation operation) + { + var opLevel = operation.Parameters?.ToList() ?? new List(); + var pathLevel = pathItem?.Parameters?.ToList() ?? new List(); + + var placeholders = Regex.Matches(path, @"\{([^}]+)\}").Select(m => m.Groups[1].Value).ToList(); + var merged = pathLevel + .Where(pp => !opLevel.Any(op => op.Name == pp.Name && op.In == pp.In)) + .Concat(opLevel) + .Where(p => p.In != ParameterLocation.Path || placeholders.Contains(p.Name ?? string.Empty)) + .ToList(); + + foreach (var name in placeholders) + { + if (!merged.Any(p => p.In == ParameterLocation.Path && p.Name == name)) + merged.Add(new OpenApiParameter + { + Name = name, + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + }); + } + + return merged; + } + + /// + /// The argsNs field for a JSON body defined in the operation that has no model (a string, an + /// array of strings, etc.): it is sent as given. An object, even a free-form one, has a model. + /// + public const string UntypedJsonBodyArgName = "body"; + + /// + /// How a JSON request body is passed to a generated function. + /// + /// The field name on argsNs that holds the body. + /// The model class generated for the body (or its items), if any. + /// True when the body is a JSON array. + /// The inline object schema a model is synthesised from, if any. + public record JsonBody(string ArgName, string? ModelName, bool IsArray, IOpenApiSchema? InlineSchema); + + /// + /// Describes a JSON request body. A body that is a model (a named or inline object), or an + /// array of models, is passed as namespaces or model instances. A $ref is named after the + /// component it refers to, which may itself be an array of models, or something else that is + /// sent as given. Returns null for any other body, which is passed in + /// and sent as given. + /// + public static JsonBody? DescribeJsonBody(IOpenApiSchema schema, string functionName, OpenApiDocument? document) + { + if (SchemaHelpers.ReferenceId(schema) is { } id) + { + var target = SchemaHelpers.Resolve(schema, document) ?? schema; + if (SchemaHelpers.IsObjectModel(target)) + return FromReference(id, id, isArray: false); + + var isArray = IsType(target, JsonSchemaType.Array); + if (isArray && SchemaHelpers.IsModelReference(target.Items, document)) + return FromReference(id, SchemaHelpers.ReferenceId(target.Items)!, isArray: true); + + return new(StringHelpers.ToValidAplName(id.ToCamelCase()), null, isArray, null); + } + + if (IsType(schema, JsonSchemaType.Array)) + { + if (SchemaHelpers.IsModelReference(schema.Items, document)) + { + var itemsId = SchemaHelpers.ReferenceId(schema.Items)!; + return FromReference(itemsId, itemsId, isArray: true); + } + + if (schema.Items != null && SchemaHelpers.IsObjectModel(schema.Items)) + return FromInline($"{functionName}RequestItem", schema.Items, isArray: true); + + return null; + } + + if (SchemaHelpers.IsObjectModel(schema)) + return FromInline($"{functionName}Request", schema, isArray: false); + + return null; + } + + /// + /// True when the schema's type is , ignoring any Null flag + /// (so a nullable array is still an array). + /// + public static bool IsType(IOpenApiSchema schema, JsonSchemaType type) => + schema.Type is { } t && (t & ~JsonSchemaType.Null) == type; + + private static JsonBody FromReference(string argId, string modelId, bool isArray) => new( + ArgName: StringHelpers.ToValidAplName(argId.ToCamelCase()), + ModelName: SchemaHelpers.ClassNameOf(modelId), + IsArray: isArray, + InlineSchema: null); + + private static JsonBody FromInline(string syntheticName, IOpenApiSchema schema, bool isArray) + { + var modelName = SchemaHelpers.ClassNameOf(syntheticName); + return new(LowerFirst(modelName), modelName, isArray, schema); + } + + private static string LowerFirst(string s) => + s.Length == 0 ? s : char.ToLowerInvariant(s[0]) + s[1..]; +} diff --git a/src/OpenAPIDyalog/Utils/PathConverter.cs b/src/OpenAPIDyalog/Utils/PathConverter.cs index 3159199..62cd0c2 100644 --- a/src/OpenAPIDyalog/Utils/PathConverter.cs +++ b/src/OpenAPIDyalog/Utils/PathConverter.cs @@ -12,7 +12,9 @@ public static class PathConverter /// /// Converts an OpenAPI path with parameter placeholders to an APL expression. - /// Example: "/user/{userId}" → "'/user/',(⍕argsNs.userId)" + /// Each parameter value is percent-encoded, so a value containing "/", "?", "#" or a + /// space stays within its own path segment. + /// Example: "/user/{userId}" → "'/user/',(c.∆.HttpCommand.UrlEncode⍕argsNs.userId)" /// public static string ToDyalogPath(string path) { @@ -30,7 +32,7 @@ public static string ToDyalogPath(string path) } var paramName = match.Groups[1].Value; - parts.Add($"(⍕argsNs.{StringHelpers.ToValidAplName(paramName)})"); + parts.Add($"(c.∆.HttpCommand.UrlEncode⍕argsNs.{StringHelpers.ToValidAplName(paramName)})"); currentIndex = match.Index + match.Length; } diff --git a/src/OpenAPIDyalog/Utils/SchemaHelpers.cs b/src/OpenAPIDyalog/Utils/SchemaHelpers.cs new file mode 100644 index 0000000..95c8466 --- /dev/null +++ b/src/OpenAPIDyalog/Utils/SchemaHelpers.cs @@ -0,0 +1,133 @@ +using CaseConverter; +using Microsoft.OpenApi; + +namespace OpenAPIDyalog.Utils; + +/// +/// Schema rules shared by model, endpoint and documentation generation. +/// +public static class SchemaHelpers +{ + /// + /// Follows a $ref to the component schema it names, and on through any components that are + /// themselves references (A → B → Pet), stopping at a cycle. Any other schema is returned as is. + /// + public static IOpenApiSchema? Resolve(IOpenApiSchema? schema, OpenApiDocument? document) + { + var seen = new HashSet(); + while (schema is OpenApiSchemaReference r && r.Reference.Id is { } id && seen.Add(id) + && document?.Components?.Schemas?.TryGetValue(id, out var resolved) == true) + schema = resolved; + return schema; + } + + /// + /// The component schema id of a $ref, or null if the schema is not a reference. + /// + public static string? ReferenceId(IOpenApiSchema? schema) => + schema is OpenApiSchemaReference r && !string.IsNullOrEmpty(r.Reference.Id) ? r.Reference.Id : null; + + /// + /// Whether a (resolved) schema describes a JSON object, and so gets a model class. Arrays and + /// primitives do not: a component that is an array of models, say, is used as a vector of them. + /// + public static bool IsObjectModel(IOpenApiSchema schema) => + IsObjectModel(schema, new HashSet(ReferenceEqualityComparer.Instance)); + + // An allOf makes an object only if one of its members does: { "allOf": [{ "type": "string" }] } is a string. + private static bool IsObjectModel(IOpenApiSchema schema, HashSet seen) => + seen.Add(schema) + && (OperationNaming.IsType(schema, JsonSchemaType.Object) + || schema.Properties is { Count: > 0 } + || schema.AdditionalProperties != null + || (schema.AllOf?.Any(member => IsObjectModel(member, seen)) ?? false)); + + /// + /// Whether a schema is a $ref to a component that gets a model class. + /// + public static bool IsModelReference(IOpenApiSchema? schema, OpenApiDocument? document) => + ReferenceId(schema) != null && Resolve(schema, document) is { } target && IsObjectModel(target); + + /// + /// Whether an enum value is JSON null. Microsoft.OpenApi reads null as a sentinel string. + /// + public static bool IsJsonNull(System.Text.Json.Nodes.JsonNode? node) => + node is null || node.IsJsonNullSentinel() + || node.GetValueKind() == System.Text.Json.JsonValueKind.Null; + + /// + /// The model class generated for a component or inline schema name. + /// + public static string ClassNameOf(string schemaName) => + StringHelpers.ToValidAplName(schemaName.ToPascalCase()); + + /// + /// Whether a (resolved) object schema has no properties of its own, so is a map of arbitrary + /// keys: it has additionalProperties, or does not forbid them (as a bare {"type": "object"} does not). + /// + public static bool IsMap(IOpenApiSchema schema) => + schema.Properties is not { Count: > 0 } + && schema.AllOf is not { Count: > 0 } + && (schema.AdditionalProperties != null || schema.AdditionalPropertiesAllowed); + + /// + /// The component schemas that get a model class, each resolved (a component that is a + /// reference to another, an alias, gets a class of its own like the one it refers to). + /// + public static IEnumerable> ModelComponents(OpenApiDocument document) => + (document.Components?.Schemas ?? new Dictionary()) + .Select(kvp => KeyValuePair.Create(kvp.Key, Resolve(kvp.Value, document) ?? kvp.Value)) + .Where(kvp => IsObjectModel(kvp.Value)); + + /// + /// An object schema's properties, including those inherited through allOf at any depth (cycle-safe). + /// Each is required if the schema, or any schema it inherits from, lists it as required. Where two + /// schemas declare the same property, the schema's own (or the nearer) declaration wins. + /// + public static List<(string Key, IOpenApiSchema Schema, bool Required)> Properties( + IOpenApiSchema schema, OpenApiDocument? document) + { + var sources = new List(); + Collect(schema, new HashSet(ReferenceEqualityComparer.Instance)); + + void Collect(IOpenApiSchema s, HashSet seen) + { + if (!seen.Add(s)) return; + sources.Add(s); + foreach (var sub in s.AllOf ?? new List()) + if (Resolve(sub, document) is { } resolved) + Collect(resolved, seen); + } + + var required = sources.SelectMany(s => s.Required ?? new HashSet()).ToHashSet(); + var result = new List<(string, IOpenApiSchema, bool)>(); + foreach (var source in sources) + { + foreach (var (key, propSchema) in source.Properties ?? new Dictionary()) + { + if (result.Any(p => p.Item1 == key)) continue; + result.Add((key, propSchema, required.Contains(key))); + } + } + return result; + } + + /// + /// Checks that no two schemas map to the same model class, which would make one overwrite + /// the other. Throws an exception naming the schemas if they do. + /// + public static void CheckModelClassNames(OpenApiDocument document) + { + var clashes = ModelComponents(document) + .Select(kvp => kvp.Key) + .GroupBy(ClassNameOf) + .Where(g => g.Count() > 1) + .Select(g => $"{string.Join(", ", g)} (all model class {g.Key})") + .ToList(); + + if (clashes.Count > 0) + throw new InvalidOperationException( + "Some schema names differ only in case or punctuation, so would generate the same model class: " + + string.Join("; ", clashes) + ". Rename one of each in the specification."); + } +} diff --git a/src/OpenAPIDyalog/Utils/SchemaTypeMapper.cs b/src/OpenAPIDyalog/Utils/SchemaTypeMapper.cs index 1cd7f92..7a65178 100644 --- a/src/OpenAPIDyalog/Utils/SchemaTypeMapper.cs +++ b/src/OpenAPIDyalog/Utils/SchemaTypeMapper.cs @@ -26,7 +26,11 @@ public static string MapSchemaTypeToAplType(IOpenApiSchema schema) return "any"; } - return schema.Type switch + // Strip Null from the flags before switching — nullable types (e.g. string|null) + // should map to the base type, not fall through to "any". + var effectiveType = schema.Type & ~JsonSchemaType.Null; + + return effectiveType switch { JsonSchemaType.String => "str", JsonSchemaType.Integer => "int", @@ -39,4 +43,11 @@ public static string MapSchemaTypeToAplType(IOpenApiSchema schema) _ => "any" }; } + + /// + /// Returns true if the schema permits null values + /// (i.e. its type flags include ). + /// + public static bool IsNullable(IOpenApiSchema schema) => + schema.Type != null && (schema.Type & JsonSchemaType.Null) != 0; } diff --git a/src/OpenAPIDyalog/Utils/StringHelpers.cs b/src/OpenAPIDyalog/Utils/StringHelpers.cs index 3a6c7a7..5efe256 100644 --- a/src/OpenAPIDyalog/Utils/StringHelpers.cs +++ b/src/OpenAPIDyalog/Utils/StringHelpers.cs @@ -104,6 +104,48 @@ public static string ToValidAplName(string name) return result.ToString(); } + /// + /// Quotes a string as an APL character vector literal, doubling any embedded quotes. Control + /// characters such as line breaks cannot appear in a literal, so are written as ⎕UCS codes: + /// "a\nb" → ('a',(⎕UCS 10),'b'). + /// + public static string ToAplString(string text) + { + if (!text.Any(char.IsControl)) + return "'" + text.Replace("'", "''") + "'"; + + var parts = new List(); + var run = new StringBuilder(); + foreach (var c in text) + { + if (!char.IsControl(c)) + { + run.Append(c == '\'' ? "''" : c.ToString()); + continue; + } + if (run.Length > 0) parts.Add($"'{run}'"); + run.Clear(); + parts.Add($"(⎕UCS {(int)c})"); + } + if (run.Length > 0) parts.Add($"'{run}'"); + + return "(" + string.Join(",", parts) + ")"; + } + + /// + /// Collapses a possibly multi-line string onto a single line, for use in a one-line + /// comment or a Markdown table cell. + /// + public static string ToSingleLine(string? text) => + string.IsNullOrWhiteSpace(text) + ? string.Empty + : string.Join(" ", text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + /// + /// Makes a string safe for a Markdown table cell: one line, with pipes escaped. + /// + public static string ToTableCell(string? text) => ToSingleLine(text).Replace("|", "\\|"); + /// /// Prefixes each line of a multiline string with the APL lamp symbol (⍝). ///