Source code for oas2mcp.models.normalized

"""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 ApiContact(NormalizedBaseModel): """Contact metadata for an API. Args: None. Returns: None. Raises: None. Examples: .. code-block:: python contact = ApiContact( name="API Support", email="support@example.com", url="https://example.com/support", ) """
[docs] name: str | None = None
[docs] email: str | None = None
[docs] url: str | None = None
[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] name: str
[docs] identifier: str | None = None
[docs] url: 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] title: str
[docs] version: str
[docs] summary: str | None = None
[docs] description: str | None = None
[docs] terms_of_service: str | None = None
[docs] contact: ApiContact | 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] url: str
[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] name: str
[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] name: str
[docs] type: str
[docs] description: str | None = None
[docs] scheme: str | None = None
[docs] bearer_format: 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] name: str
[docs] location: str
[docs] required: bool = False
[docs] description: str | None = None
[docs] schema_type: str | None = None
[docs] schema_format: 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 ApiMediaType(NormalizedBaseModel): """Normalized media type description. Args: None. Returns: None. Raises: None. Examples: .. code-block:: python media = ApiMediaType( content_type="application/json", schema_type="object", ) """
[docs] content_type: str
[docs] schema_ref: str | None = None
[docs] schema_type: str | None = None
[docs] example: Any | None = None
[docs] examples: dict[str, Any] = Field(default_factory=dict)
[docs] raw_schema: dict[str, Any] = Field(default_factory=dict)
[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] status_code: str
[docs] description: str | None = None
[docs] media_types: list[ApiMediaType] = Field(default_factory=list)
[docs] headers: dict[str, Any] = Field(default_factory=dict)
[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] method: str
[docs] path: str
[docs] operation_id: str | None = None
[docs] summary: str | None = None
[docs] description: str | None = None
[docs] tags: list[str] = Field(default_factory=list)
[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] path: str
[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] name: str
[docs] source_uri: str
[docs] openapi_version: str | None = None
[docs] info: ApiInfo | None = None
[docs] servers: list[ApiServer] = Field(default_factory=list)
[docs] tags: list[ApiTag] = 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]