Skip to content

tomgeene/openapi_parser

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenapiParser

CI Hex.pm Documentation

A comprehensive OpenAPI specification parser for Elixir that supports:

  • OpenAPI 2.0 (Swagger)
  • OpenAPI 3.0
  • OpenAPI 3.1

Both JSON and YAML formats are supported with full validation.

Features

  • âś… Complete OpenAPI Support - Parse all three major versions
  • âś… Format Flexible - JSON and YAML with auto-detection
  • âś… Comprehensive Validation - Validates required fields, types, formats, and semantic rules
  • âś… Type Safe - Full Elixir typespecs for all structs
  • âś… Well Tested - Extensive test coverage with boundary value testing
  • âś… Production Ready - Used for building API clients, documentation generators, and validation tools

Installation

Add openapi_parser to your list of dependencies in mix.exs:

def deps do
  [
    {:openapi_parser, "~> 0.1.0"}
  ]
end

Then run:

mix deps.get

Quick Start

Parse from a String

# Parse JSON
json_spec = """
{
  "openapi": "3.1.0",
  "info": {
    "title": "My API",
    "version": "1.0.0"
  },
  "paths": {
    "/users": {
      "get": {
        "responses": {
          "200": {
            "description": "Success"
          }
        }
      }
    }
  }
}
"""

{:ok, spec} = OpenapiParser.parse(json_spec, format: :json)
# => {:ok, %OpenapiParser.Spec.OpenAPI{version: :v3_1, document: ...}}

# Parse YAML
yaml_spec = """
openapi: 3.0.0
info:
  title: My API
  version: 1.0.0
paths:
  /users:
    get:
      responses:
        '200':
          description: Success
"""

{:ok, spec} = OpenapiParser.parse(yaml_spec, format: :yaml)

Parse from a File

# Auto-detects format from file extension
{:ok, spec} = OpenapiParser.parse_file("openapi.json")
{:ok, spec} = OpenapiParser.parse_file("swagger.yaml")

# Access the parsed data
IO.puts(spec.document.info.title)
# => "My API"

IO.puts(spec.document.info.version)
# => "1.0.0"

Parsing Options

All parsing functions accept the following options:

  • :format - Input format (:json, :yaml, or :auto). Default: :auto
  • :validate - Whether to validate the spec. Default: true
  • :resolve_refs - Whether to resolve $ref references. Default: false
# Parse without validation (faster, but may accept invalid specs)
{:ok, spec} = OpenapiParser.parse(content, validate: false)

# Parse with reference resolution
{:ok, spec} = OpenapiParser.parse_file("spec.yaml", resolve_refs: true)

# Explicit format
{:ok, spec} = OpenapiParser.parse(content, format: :json, validate: true)

Working with Parsed Specs

Access Spec Information

{:ok, spec} = OpenapiParser.parse_file("openapi.yaml")

# Check version
case spec.version do
  :v2 -> IO.puts("Swagger 2.0")
  :v3_0 -> IO.puts("OpenAPI 3.0")
  :v3_1 -> IO.puts("OpenAPI 3.1")
end

# Access info
info = spec.document.info
IO.puts("Title: #{info.title}")
IO.puts("Version: #{info.version}")
IO.puts("Description: #{info.description}")

# Access contact information
if info.contact do
  IO.puts("Contact: #{info.contact.name}")
  IO.puts("Email: #{info.contact.email}")
end

# Access license
if info.license do
  IO.puts("License: #{info.license.name}")
end

Iterate Over Paths

{:ok, spec} = OpenapiParser.parse_file("openapi.yaml")

# List all paths
Enum.each(spec.document.paths, fn {path, path_item} ->
  IO.puts("Path: #{path}")

  # Check which HTTP methods are defined
  if path_item.get, do: IO.puts("  - GET")
  if path_item.post, do: IO.puts("  - POST")
  if path_item.put, do: IO.puts("  - PUT")
  if path_item.delete, do: IO.puts("  - DELETE")
end)

Access Operation Details

{:ok, spec} = OpenapiParser.parse_file("openapi.yaml")

# Get a specific path
path_item = spec.document.paths["/users/{id}"]

# Access GET operation
if path_item.get do
  operation = path_item.get

  IO.puts("Operation ID: #{operation.operation_id}")
  IO.puts("Summary: #{operation.summary}")
  IO.puts("Tags: #{inspect(operation.tags)}")

  # List parameters
  if operation.parameters do
    Enum.each(operation.parameters, fn param ->
      IO.puts("Parameter: #{param.name} (#{param.location})")
      IO.puts("  Required: #{param.required}")
    end)
  end

  # List responses
  Enum.each(operation.responses.responses, fn {status, response} ->
    IO.puts("Response #{status}: #{response.description}")
  end)
end

Access Components/Schemas

{:ok, spec} = OpenapiParser.parse_file("openapi.yaml")

# For OpenAPI 3.x
if spec.document.components do
  schemas = spec.document.components.schemas

  Enum.each(schemas, fn {name, schema} ->
    IO.puts("Schema: #{name}")
    IO.puts("  Type: #{schema.type}")

    if schema.properties do
      IO.puts("  Properties:")
      Enum.each(schema.properties, fn {prop_name, prop_schema} ->
        IO.puts("    - #{prop_name}: #{prop_schema.type}")
      end)
    end
  end)
end

# For Swagger 2.0
if spec.version == :v2 and spec.document.definitions do
  Enum.each(spec.document.definitions, fn {name, schema} ->
    IO.puts("Definition: #{name}")
  end)
end

Building an API Client

Here's a practical example of using the parser to build an API client:

defmodule MyAPIClient do
  def build_from_spec(spec_path) do
    {:ok, spec} = OpenapiParser.parse_file(spec_path, validate: true)

    # Extract server URL
    server_url = case spec.version do
      :v2 ->
        scheme = List.first(spec.document.schemes) || "https"
        "#{scheme}://#{spec.document.host}#{spec.document.base_path}"
      _ ->
        List.first(spec.document.servers).url
    end

    # Build client functions for each operation
    operations = extract_operations(spec.document.paths)

    %{
      base_url: server_url,
      operations: operations,
      info: spec.document.info
    }
  end

  defp extract_operations(paths) do
    Enum.flat_map(paths, fn {path, path_item} ->
      [:get, :post, :put, :delete, :patch]
      |> Enum.filter(&Map.get(path_item, &1))
      |> Enum.map(fn method ->
        operation = Map.get(path_item, method)
        %{
          method: method,
          path: path,
          operation_id: operation.operation_id,
          summary: operation.summary,
          parameters: operation.parameters || []
        }
      end)
    end)
  end
end

# Use it
client = MyAPIClient.build_from_spec("api_spec.yaml")
IO.puts("API: #{client.info.title}")
IO.puts("Base URL: #{client.base_url}")
IO.puts("Available operations: #{length(client.operations)}")

Validation

The parser performs comprehensive validation including:

  • Required fields - Ensures all required fields are present
  • Type checking - Validates field types (string, integer, boolean, etc.)
  • Format validation - Validates string formats (email, uri, url, uuid, etc.)
  • Enum validation - Checks enumerated values
  • Semantic rules - e.g., path parameters must be required, paths must start with "/"
  • HTTP status codes - Validates status code formats
  • Content types - Validates media type formats

Handling Validation Errors

case OpenapiParser.parse(invalid_spec, validate: true) do
  {:ok, spec} ->
    IO.puts("Valid spec!")

  {:error, message} ->
    IO.puts("Validation error: #{message}")
    # Example: "paths./users.get.parameters[0]: Path parameter must be required"
end

Supported Specifications

OpenAPI 3.1

  • Full JSON Schema 2020-12 support
  • Type arrays (e.g., type: ["string", "null"])
  • const keyword
  • License identifier field
  • And all OpenAPI 3.0 features

OpenAPI 3.0

  • Components (schemas, responses, parameters, etc.)
  • Request bodies with multiple content types
  • Callbacks and links
  • Discriminators
  • Security schemes (apiKey, http, oauth2, openIdConnect)
  • Server variables
  • Cookie parameters

Swagger 2.0

  • Definitions
  • Parameters (including formData and file types)
  • Responses with headers
  • Security definitions
  • Global consumes/produces
  • Collection formats

Error Handling

# File not found
{:error, message} = OpenapiParser.parse_file("nonexistent.yaml")

# Invalid JSON/YAML
{:error, message} = OpenapiParser.parse("{invalid json", format: :json)

# Missing version
{:error, message} = OpenapiParser.parse("""
{
  "info": {"title": "API", "version": "1.0.0"},
  "paths": {}
}
""")
# => {:error, "Missing version field (swagger or openapi)"}

# Validation errors
{:error, message} = OpenapiParser.parse("""
{
  "openapi": "3.0.0",
  "info": {"title": "API", "version": "1.0.0"}
}
""", validate: true)
# => {:error, "paths is required"}

Advanced Usage

Custom Validation

defmodule MyValidator do
  def validate_custom_rules(spec) do
    with {:ok, spec} <- OpenapiParser.parse_file("spec.yaml"),
         :ok <- validate_operation_ids(spec),
         :ok <- validate_descriptions(spec) do
      {:ok, spec}
    end
  end

  defp validate_operation_ids(spec) do
    operations = extract_all_operations(spec.document.paths)
    operation_ids = Enum.map(operations, & &1.operation_id)

    if length(operation_ids) == length(Enum.uniq(operation_ids)) do
      :ok
    else
      {:error, "Duplicate operation IDs found"}
    end
  end

  defp validate_descriptions(spec) do
    # Ensure all operations have descriptions
    # ... custom validation logic
    :ok
  end
end

Generating Documentation

defmodule DocGenerator do
  def generate_markdown(spec_path) do
    {:ok, spec} = OpenapiParser.parse_file(spec_path)

    """
    # #{spec.document.info.title}

    Version: #{spec.document.info.version}

    #{spec.document.info.description}

    ## Endpoints

    #{generate_paths_doc(spec.document.paths)}
    """
  end

  defp generate_paths_doc(paths) do
    paths
    |> Enum.map(fn {path, path_item} ->
      generate_path_doc(path, path_item)
    end)
    |> Enum.join("\n\n")
  end

  defp generate_path_doc(path, path_item) do
    """
    ### #{path}

    #{generate_operations_doc(path_item)}
    """
  end

  defp generate_operations_doc(path_item) do
    [:get, :post, :put, :delete]
    |> Enum.filter(&Map.get(path_item, &1))
    |> Enum.map(fn method ->
      operation = Map.get(path_item, method)
      "- **#{String.upcase(to_string(method))}**: #{operation.summary || operation.description}"
    end)
    |> Enum.join("\n")
  end
end

Performance Considerations

  • Parsing is generally fast, even for large specifications (10,000+ lines)
  • Validation adds ~20-30% overhead
  • Reference resolution can be expensive for deeply nested references
  • For best performance: parse once, cache the result
# Good: Parse once
{:ok, spec} = OpenapiParser.parse_file("spec.yaml")
# Use spec multiple times...

# Avoid: Parsing repeatedly
Enum.each(1..100, fn _ ->
  {:ok, spec} = OpenapiParser.parse_file("spec.yaml")  # Don't do this!
end)

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Changelog

See CHANGELOG.md for version history and changes.

Resources

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages