"""Normalized OpenAPI models.
Purpose:
Define stable, typed Pydantic models for the normalized internal view of
an OpenAPI specification.
Design:
- Flatten OpenAPI structures into reusable, agent-friendly models.
- Preserve enough metadata for Rich rendering, inspection, and later MCP
generation.
- Use clean internal field names instead of OpenAPI alias names where the
alias would be awkward or invalid in Python, such as ``in``.
- Keep raw fragments available for debugging and future enrichment.
Examples:
.. code-block:: python
from oas2mcp.models.normalized import ApiCatalog, ApiOperation
operation = ApiOperation(
method="get",
path="/pets/{petId}",
operation_id="getPetById",
summary="Fetch one pet",
)
catalog = ApiCatalog(
name="Petstore",
source_uri="https://petstore3.swagger.io/api/v3/openapi.json",
operations=[operation],
)
"""
from __future__ import annotations
from typing import Any, ClassVar
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
[docs]
HTTP_METHODS: tuple[str, ...] = (
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"TRACE",
)
__all__ = [
"HTTP_METHODS",
"NormalizedBaseModel",
"ApiContact",
"ApiLicense",
"ApiInfo",
"ApiServer",
"ApiTag",
"ApiSecurityRequirement",
"ApiSecurityScheme",
"ApiParameter",
"ApiMediaType",
"ApiRequestBody",
"ApiResponse",
"ApiOperation",
"ApiPathItem",
"ApiCatalog",
]
[docs]
class NormalizedBaseModel(BaseModel):
"""Base model for normalized OpenAPI objects.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
class ExampleModel(NormalizedBaseModel):
name: str
"""
[docs]
model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
validate_assignment=True,
str_strip_whitespace=True,
)
[docs]
class ApiLicense(NormalizedBaseModel):
"""License metadata for an API.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
license_info = ApiLicense(name="MIT")
"""
[docs]
identifier: str | None = None
[docs]
class ApiInfo(NormalizedBaseModel):
"""Top-level API metadata.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
info = ApiInfo(
title="Petstore",
version="1.0.0",
summary="Pet operations",
)
"""
[docs]
summary: str | None = None
[docs]
description: str | None = None
[docs]
terms_of_service: str | None = None
[docs]
license: ApiLicense | None = None
[docs]
class ApiServer(NormalizedBaseModel):
"""Resolved server definition.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
server = ApiServer(
url="https://api.example.com",
description="Production",
)
"""
[docs]
description: str | None = None
[docs]
variables: dict[str, dict[str, Any]] = Field(default_factory=dict)
[docs]
class ApiTag(NormalizedBaseModel):
"""Normalized tag metadata.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
tag = ApiTag(
name="pets",
description="Operations about pets.",
)
"""
[docs]
description: str | None = None
[docs]
external_docs_description: str | None = None
[docs]
external_docs_url: str | None = None
[docs]
class ApiSecurityRequirement(NormalizedBaseModel):
"""One normalized security requirement mapping.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
requirement = ApiSecurityRequirement(
scheme_names=["bearerAuth"],
)
"""
[docs]
scheme_names: list[str] = Field(default_factory=list)
[docs]
class ApiSecurityScheme(NormalizedBaseModel):
"""One named security scheme.
Design:
``name`` is the scheme identifier from ``components.securitySchemes``.
``parameter_name`` is the header/query/cookie parameter name used by
some schemes such as API key auth.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
scheme = ApiSecurityScheme(
name="apiKeyAuth",
type="apiKey",
location="header",
parameter_name="X-API-Key",
)
"""
[docs]
description: str | None = None
[docs]
scheme: str | None = None
[docs]
location: str | None = None
[docs]
parameter_name: str | None = None
[docs]
open_id_connect_url: str | None = None
[docs]
flows: dict[str, Any] = Field(default_factory=dict)
[docs]
class ApiParameter(NormalizedBaseModel):
"""Normalized operation parameter.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
parameter = ApiParameter(
name="petId",
location="path",
required=True,
schema_type="integer",
)
"""
[docs]
description: str | None = None
[docs]
schema_type: str | None = None
[docs]
default: Any | None = None
[docs]
enum_values: list[Any] = Field(default_factory=list)
[docs]
raw_schema: dict[str, Any] = Field(default_factory=dict)
@field_validator("location")
@classmethod
[docs]
def validate_location(cls, value: str) -> str:
"""Validate a parameter location.
Args:
value: The parameter location.
Returns:
The validated location.
Raises:
ValueError: If the location is unsupported.
Examples:
.. code-block:: python
ApiParameter(
name="petId",
location="path",
)
"""
allowed = {"path", "query", "header", "cookie"}
if value not in allowed:
raise ValueError(
f"Unsupported parameter location {value!r}. Expected one of {sorted(allowed)}."
)
return value
[docs]
class ApiRequestBody(NormalizedBaseModel):
"""Normalized request body definition.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
body = ApiRequestBody(
required=True,
media_types=[ApiMediaType(content_type="application/json")],
)
"""
[docs]
required: bool = False
[docs]
description: str | None = None
[docs]
media_types: list[ApiMediaType] = Field(default_factory=list)
[docs]
class ApiResponse(NormalizedBaseModel):
"""Normalized response definition.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
response = ApiResponse(
status_code="200",
description="Success",
)
"""
[docs]
description: str | None = None
[docs]
class ApiOperation(NormalizedBaseModel):
"""One normalized HTTP operation.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
operation = ApiOperation(
method="get",
path="/pets/{petId}",
operation_id="getPetById",
)
"""
[docs]
operation_id: str | None = None
[docs]
summary: str | None = None
[docs]
description: str | None = None
[docs]
parameters: list[ApiParameter] = Field(default_factory=list)
[docs]
request_body: ApiRequestBody | None = None
[docs]
responses: list[ApiResponse] = Field(default_factory=list)
[docs]
security: list[ApiSecurityRequirement] = Field(default_factory=list)
[docs]
deprecated: bool = False
[docs]
external_docs_description: str | None = None
[docs]
external_docs_url: str | None = None
[docs]
raw_operation: dict[str, Any] = Field(default_factory=dict)
_http_methods: ClassVar[set[str]] = set(HTTP_METHODS)
@field_validator("method")
@classmethod
[docs]
def normalize_method(cls, value: str) -> str:
"""Normalize and validate an HTTP method.
Args:
value: The input HTTP method.
Returns:
The normalized uppercase HTTP method.
Raises:
ValueError: If the method is unsupported.
Examples:
.. code-block:: python
ApiOperation(
method="get",
path="/pets",
)
"""
normalized = value.upper()
if normalized not in cls._http_methods:
raise ValueError(
f"Unsupported HTTP method {value!r}. Expected one of {sorted(cls._http_methods)}."
)
return normalized
@field_validator("path")
@classmethod
[docs]
def normalize_path(cls, value: str) -> str:
"""Normalize a path string.
Args:
value: The raw path string.
Returns:
The normalized path starting with ``/``.
Raises:
ValueError: If the path is empty.
Examples:
.. code-block:: python
ApiOperation(
method="GET",
path="pets/{petId}",
)
"""
cleaned = value.strip()
if not cleaned:
raise ValueError("Operation path cannot be empty.")
if not cleaned.startswith("/"):
cleaned = f"/{cleaned}"
return cleaned
@computed_field
@property
[docs]
def key(self) -> str:
"""Return a stable operation lookup key.
Args:
None.
Returns:
A ``METHOD path`` lookup key.
Raises:
None.
Examples:
.. code-block:: python
operation = ApiOperation(method="GET", path="/pets")
assert operation.key == "GET /pets"
"""
return f"{self.method} {self.path}"
@computed_field
@property
[docs]
def is_mutating(self) -> bool:
"""Return whether the operation mutates remote state.
Args:
None.
Returns:
``True`` for mutating HTTP methods.
Raises:
None.
Examples:
.. code-block:: python
operation = ApiOperation(method="POST", path="/pets")
assert operation.is_mutating is True
"""
return self.method in {"POST", "PUT", "PATCH", "DELETE"}
[docs]
class ApiPathItem(NormalizedBaseModel):
"""Normalized path item grouping multiple operations.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
item = ApiPathItem(
path="/pets",
operations=[],
)
"""
[docs]
parameters: list[ApiParameter] = Field(default_factory=list)
[docs]
operations: list[ApiOperation] = Field(default_factory=list)
@field_validator("path")
@classmethod
[docs]
def normalize_path(cls, value: str) -> str:
"""Normalize a path item path.
Args:
value: The raw path string.
Returns:
The normalized path string.
Raises:
ValueError: If the path is empty.
Examples:
.. code-block:: python
ApiPathItem(path="/pets")
"""
cleaned = value.strip()
if not cleaned:
raise ValueError("Path item path cannot be empty.")
if not cleaned.startswith("/"):
cleaned = f"/{cleaned}"
return cleaned
[docs]
class ApiCatalog(NormalizedBaseModel):
"""Top-level normalized API catalog.
Args:
None.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
catalog = ApiCatalog(
name="Petstore",
source_uri="https://example.com/openapi.json",
)
"""
[docs]
openapi_version: str | None = None
[docs]
info: ApiInfo | None = None
[docs]
servers: list[ApiServer] = Field(default_factory=list)
[docs]
security_schemes: list[ApiSecurityScheme] = Field(default_factory=list)
[docs]
global_security: list[ApiSecurityRequirement] = Field(default_factory=list)
[docs]
paths: list[ApiPathItem] = Field(default_factory=list)
[docs]
operations: list[ApiOperation] = Field(default_factory=list)
[docs]
component_counts: dict[str, int] = Field(default_factory=dict)
[docs]
raw_spec: dict[str, Any] = Field(default_factory=dict)
@computed_field
@property
[docs]
def operation_count(self) -> int:
"""Return the number of normalized operations.
Args:
None.
Returns:
The number of operations in the catalog.
Raises:
None.
Examples:
.. code-block:: python
catalog = ApiCatalog(
name="Petstore",
source_uri="https://example.com/openapi.json",
)
assert catalog.operation_count == 0
"""
return len(self.operations)
@computed_field
@property
[docs]
def tag_names(self) -> list[str]:
"""Return all tag names.
Args:
None.
Returns:
A list of tag names.
Raises:
None.
Examples:
.. code-block:: python
catalog = ApiCatalog(
name="Petstore",
source_uri="https://example.com/openapi.json",
)
assert catalog.tag_names == []
"""
return [tag.name for tag in self.tags]