Metadata-Version: 2.1
Name: edpopenapibuilder
Version: 0.4.0
Summary: E-Deploy OpenAPI Builder
Author-email: Max Guenes <max.santos@e-deploy.com.br>
Maintainer-email: Max Guenes <max.santos@e-deploy.com.br>
Requires-Python: <4,>=3.8
Description-Content-Type: text/markdown
Requires-Dist: httpmessageprocessor<5,>=2.1
Requires-Dist: messageprocessor~=4.1
Requires-Dist: PyYAML~=6.0
Requires-Dist: StrEnum==0.4.15
Provides-Extra: dependencyinjection
Requires-Dist: DiHttpMessageProcessor<3.0.0,>=2.0.0; extra == "dependencyinjection"
Provides-Extra: pydantic
Requires-Dist: pydantic<3.0,>=2.0; extra == "pydantic"

# E-Deploy Open API Builder

Automatically generates OpenAPI 3.x YAML documentation for Python applications built on the `httpmessageprocessor`/`messageprocessor` framework. The library introspects your routes, processors, and interactors at runtime — deriving request/response schemas, path parameters, and query parameters directly from Python type annotations, with no manual spec writing required.

## How it works

Routes map to `MessageProcessor` instances that delegate to typed `Interactor` classes:

```
Route → MessageProcessor → Interactor.execute(RequestType) -> ResponseType
```

The builder walks this chain via `inspect`, resolves `Optional`, `List`, `Union`, and `Enum` generics, and emits `$ref` entries in `components/schemas` for complex object types. GET/DELETE routes produce query parameters; all other methods produce a `requestBody`.

## Installation

```bash
# Core
pip install edpopenapibuilder

# With dependency-injection support
pip install "edpopenapibuilder[dependencyinjection]"
```

## Usage

### 1. Dependency injection (recommended)

Load your module and `edpopenapibuilder.injection` together. The builder registers itself automatically and exposes `GET /oas/yaml`.

```python
import yourhttpmodule
import edpopenapibuilder.injection
from dependencyinjection import DependencyLoader
from dihttpmessageprocessor import AutoMappedDiRouterProcessorBuilder

dependencies_loader = DependencyLoader()
dependencies_loader.load_modules(
    modules=[
        yourhttpmodule,
        edpopenapibuilder.injection,
    ],
)

router_processor_builder = AutoMappedDiRouterProcessorBuilder(
    dependency_context=dependencies_loader.build_context(),
)
```

Or invoke the interactor directly:

```python
from edpopenapibuilder.injection.interactor import DependencyInjectionApiYamlBuilderInteractor
from edpopenapibuilder.interactor.dto import AutoOpenApiYamlBuilderRequest

interactor = DependencyInjectionApiYamlBuilderInteractor(
    router_processor_builder=router_processor_builder,
)
yaml_output = interactor.execute(AutoOpenApiYamlBuilderRequest())
```

### 2. Append route to an existing route dict

```python
from edpopenapibuilder.route import append_open_api_build_route

routes = {
    Route("GET", "/items"): AutoMappedMessageProcessor(
        interactor_type=ListItemsInteractor,
        request_type=ListItemsRequest,
    ),
}

routes = append_open_api_build_route(routes)
message_handler = HttpMessageProcessorMessageHandler(routes)
```

### 3. Build from a `MessageHandlerBuilder`

```python
from edpopenapibuilder.builder.helper import build_yaml_from_message_handler_builder

yaml_output = build_yaml_from_message_handler_builder(my_message_handler_builder)
```

The OpenAPI endpoint is available at:

```
GET {HOST}/oas/yaml
```

The `host` header is read automatically and added as the server URL in the spec.

## Configuration

The builder accepts a `BuilderConfig` TypedDict to control its behavior. Currently the main option is `conflict_strategy`, which determines how schema name conflicts are handled when the same DTO is used across routes with different path parameters.

### Schema conflict strategies

| Strategy | Behavior | Example |
|----------|----------|---------|
| `readable` (default) | Appends a `Without{ParamNames}` suffix | `AllTypesDtoWithoutId` |
| `reuse` | Reuses the same schema for all routes | `AllTypesDto` (single schema) |
| `uuid` | Appends a hash suffix on conflict | `AllTypesDto-502d8fd31d` |

```python
from edpopenapibuilder.builder.dto import BuilderConfig, SchemaConflictStrategy
from edpopenapibuilder.builder.impl import OpenApiModelBuilderImpl

# Pass config directly to the builder
builder = OpenApiModelBuilderImpl(config={
    "conflict_strategy": SchemaConflictStrategy.readable,
})
```

Or set a global default via the registry:

```python
from edpopenapibuilder.builder.dto import BuilderConfig, SchemaConflictStrategy
from edpopenapibuilder.builder.registry import set_default_config

set_default_config({
    "conflict_strategy": SchemaConflictStrategy.reuse,
})
```

Or pass config through the interactors:

```python
from edpopenapibuilder.injection.interactor import DependencyInjectionApiYamlBuilderInteractor
from edpopenapibuilder.interactor.dto import AutoOpenApiYamlBuilderRequest

interactor = DependencyInjectionApiYamlBuilderInteractor(
    router_processor_builder=router_processor_builder,
    config={"conflict_strategy": SchemaConflictStrategy.readable},
)
yaml_output = interactor.execute(AutoOpenApiYamlBuilderRequest())
```

## Customising generated output

Use **descriptors** to override or augment auto-generated paths and schemas without subclassing. Register them before the builder runs.

```python
from edpopenapibuilder.builder.registry import (
    add_route_descriptor,
    add_interactor_descriptor,
    add_payload_descriptor,
)
from edpopenapibuilder.builder.descriptors import path_descriptor, payload_descriptor
from edpopenapibuilder.builder import OpenApiModelBuilder
from edpopenapibuilder.builder.dto import RouteBuildParams
from edpopenapibuilder.model import PathModel, SchemaModel

# Augment a path by route
def describe_my_route(builder: OpenApiModelBuilder, params: RouteBuildParams, path: PathModel) -> PathModel:
    path.summary = "My endpoint summary"
    path.tags.append("my-tag")
    return path

add_route_descriptor(Route("GET", "/items"), path_descriptor(update=describe_my_route))

# Augment a path by interactor type
add_interactor_descriptor(MyInteractor, path_descriptor(update=describe_my_route))

# Augment a schema by payload type
def describe_my_dto(builder, params, schema: SchemaModel, path=None) -> SchemaModel:
    schema.description = "My DTO description"
    return schema

add_payload_descriptor(MyDto, payload_descriptor(update=describe_my_dto))
```

Each descriptor accepts `create` and `update` callbacks:
- `create` — replaces the default build logic entirely. **First-hit wins**: the registry walks descriptors in priority order (route → processor → interactor for paths; payload for schemas) and uses the first descriptor whose `create` callback is defined. Descriptors registered with only `update` do not short-circuit this lookup.
- `update` — receives the auto-built result and returns a modified version. **All matching descriptors run**: every route, processor, interactor (and payload, where applicable) descriptor whose `update` callback is defined will fire, in priority order.

Path descriptors also support a `build_params` callback (first-hit wins across route → processor).

### Route regex matching

Route descriptors support regex patterns. If no exact route match is found, the registry falls back to `re.fullmatch` against registered patterns:

```python
# Matches any GET route under /api/v1/
add_route_descriptor(
    Route("GET", "/api/v1/.+"),
    path_descriptor(update=describe_my_route),
)

# Matches routes with numeric IDs
add_route_descriptor(
    Route("GET", "/items/[0-9]+/details"),
    path_descriptor(update=describe_my_route),
)
```

Exact route matches always take priority over regex patterns.

### Type inheritance matching

Interactor, processor, and payload descriptors support type inheritance. A descriptor registered for a base class also matches its subclasses:

```python
class BaseInteractor:
    pass

class SpecificInteractor(BaseInteractor):
    pass

# This descriptor applies to SpecificInteractor (and any other subclass)
add_interactor_descriptor(BaseInteractor, path_descriptor(update=describe_my_route))

# Same for payload types
add_payload_descriptor(BaseDto, payload_descriptor(update=describe_my_dto))
```

Exact type matches always take priority over inheritance matches.

## Extra mapping support

When an `AutoMappedMessageProcessor` uses `extra_mapping` to map request fields from non-standard locations (headers, query parameters), the builder automatically:

1. Creates the corresponding OpenAPI parameter with the correct `in` location (`header`, `query`, or `path`)
2. Excludes the mapped field from the request body schema to avoid duplication

```python
Route("POST", "/items/{id}/archive"): AutoMappedMessageProcessor(
    ArchiveItemRequest,
    archive_item_interactor,
    extra_mapping={
        "tenant_id": {
            "name": "X-Tenant-Id",
            "location": "header",
        }
    },
)
```

This generates a required `X-Tenant-Id` header parameter and removes `tenant_id` from the request body schema. If all fields in the request type are covered by path parameters and extra mappings, the `requestBody` is omitted entirely.

Path-parameter coverage is matched across naming conventions: a snake_case request field (e.g. `item_id`) is recognised as covered whether the URL declares the parameter as `{item_id}`, `{itemId}`, `{item-id}`, or `{ItemId}`, mirroring `httpmessageprocessor`'s own attribute resolution.

## Operation IDs

Every operation gets an auto-generated `operationId` derived from the method and path: the lowercased HTTP method followed by camelCased path segments (parameter braces stripped).

| Method + path | `operationId` |
|---|---|
| `GET /users` | `getUsers` |
| `GET /users/{id}` | `getUsersId` |
| `POST /configurationGroup/{configuration_id}/notificationUser` | `postConfigurationGroupConfigurationIdNotificationUser` |

Override it from a descriptor `update` callback with `path.set(operation_id="...")`, or via route metadata (see below).

## Route-level OpenAPI metadata

Attach per-operation metadata (summary, description, tags, etc.) **at the route definition**, without descriptors and without importing this library in your production code. Place a reserved `__openapi__` key inside the route's `extra_mapping`:

```python
from edpopenapibuilder.builder.dto import OPENAPI_METADATA_KEY  # == "__openapi__"

Route("GET", "/notification"): RouteDefinition(
    interactor_type=ListNotificationsInteractor,
    request_type=NotificationListRequest,
    extra_mapping={
        "tenant_id": {"name": "X-Tenant-Id", "location": "header"},
        "__openapi__": {
            "summary": "List notifications",
            "description": "Returns notifications for the current user",
            "tags": ["notification"],
            "deprecated": False,
            "operationId": "listNotifications",
            "security": [{"BearerAuth": []}],          # or {"BearerAuth": ["read"]}
            "extensions": {"secured": True},            # emitted as x-secured
            "externalDocs": {"url": "https://docs.example.com/notifications"},
        },
    },
)
```

The same shape works for a directly-constructed `AutoMappedMessageProcessor(..., extra_mapping=...)` (non-DI). Supported keys: `summary`, `description`, `tags`, `deprecated`, `operationId`/`operation_id`, `security` (native list or `{name: scopes}` dict), `extensions` (dict, emitted as `x-` keys), `externalDocs`/`external_docs` (dict or `ExternalDocsModel`).

Notes:
- The `__openapi__` key is stripped before parameter generation (it never becomes a bogus parameter) and is ignored by the production request parser, so it is runtime-safe and needs no changes to `httpmessageprocessor`/`dihttpmessageprocessor`.
- Route metadata is applied **after** the default build and after update descriptors, so it overrides global descriptor rules for the fields it sets.

## Vendor extensions (`x-*`)

Every model supports OpenAPI specification extensions through two fluent methods (available in descriptor callbacks and anywhere you hold a model):

```python
path.with_extensions(internal=True)          # -> x-internal: true
path.add_extension("x-logo", {"url": "..."}) # explicit key, emitted verbatim
```

The `x-` prefix is added automatically and snake_case becomes kebab-case (`code_samples` → `x-code-samples`); keys already starting with `x-` are kept as-is. Read them back with `path.get_extensions()` or `path.has_extension("internal")` (the membership check matches with or without the `x-` prefix) instead of touching the private attribute.

## Adding server metadata

```python
from edpopenapibuilder.builder.registry import get_builder
from edpopenapibuilder.model import ServerModel, ServerVariableModel

builder = get_builder()
server = ServerModel(url="https://api.example.com", description="Production")
server.add_variable("env", ServerVariableModel(default="prod", description="Environment"))
builder.get_model().add_server(server)
```
