mix pd.scaffold (PropertyDamage v0.2.0)

View Source

Generate a complete PropertyDamage test suite from an OpenAPI specification.

This dramatically reduces setup time for testing REST APIs by automatically generating:

  • Command modules with generators
  • Event structs from response schemas
  • HTTP adapter with execute clauses
  • Model module with command weights
  • Authentication support

Usage

# Generate everything from an OpenAPI spec
mix pd.scaffold --from openapi.json --output lib/my_app_test/

# From a URL
mix pd.scaffold --from https://api.example.com/openapi.json --output lib/

# Only specific operations
mix pd.scaffold --from openapi.json --operations createUser,updateUser

# Generate only commands (skip adapter/model)
mix pd.scaffold --from openapi.json --commands-only

# Preview without writing files
mix pd.scaffold --from openapi.json --dry-run

Options

  • --from - Path or URL to OpenAPI spec (JSON or YAML)
  • --output - Output directory for generated files (default: lib/generated/)
  • --operations - Comma-separated list of operationIds to generate
  • --namespace - Module namespace prefix (e.g., MyAppTest)
  • --commands-only - Only generate command modules
  • --dry-run - Print what would be generated without writing files
  • --base-url - Base URL for the API (overrides spec's servers)

What Gets Generated

Commands (one per operation)

defmodule MyAppTest.Commands.CreateUser do
  @behaviour PropertyDamage.Command
  import PropertyDamage.Generator, only: [merge_overrides: 2]
  defstruct [:name, :email, :role]

  @impl true
  def generator(overrides \\ %{}) do
    %{
      name: StreamData.string(:alphanumeric, min_length: 5, max_length: 20),
      email: StreamData.map(StreamData.positive_integer(), &"test_email_#{&1}@example.com"),
      role: StreamData.member_of(["admin", "user", "guest"])
    }
    |> merge_overrides(overrides)
    |> StreamData.fixed_map()
  end

  # Fill in to map an HTTP response to events (status-aware):
  def events(_command, 201, body), do: [%MyAppTest.Events.UserCreated{id: body["id"]}]
  def events(_command, _status, _body), do: []
end

Events (from response schemas)

defmodule MyAppTest.Events.UserCreated do
  import PropertyDamage, only: [external: 0]
  defstruct [id: external(), name: nil, email: nil, role: nil, created_at: nil]
end

Adapter

defmodule MyAppTest.Adapter do
  use PropertyDamage.Adapter

  @impl true
  def execute(%Commands.CreateUser{} = cmd, ctx) do
    # ... build url/body, then map the response to events via the command:
    case http_request(:post, url, body, []) do
      {:ok, status, response} -> {:ok, cmd.__struct__.events(cmd, status, response)}
      {:error, reason} -> {:error, reason}
    end
  end
  # ...
end

Model

defmodule MyAppTest.Model do
  @behaviour PropertyDamage.Model

  def commands do
    [
      {Commands.CreateUser, weight: 5},
      {Commands.GetUser, weight: 3},
      # ...
    ]
  end

  def command_sequence_projection, do: MyAppTest.Projections.State
  def assertion_projections, do: [MyAppTest.Projections.UniqueUsers]
end

YAML Support

YAML files (.yaml, .yml) are supported if yaml_elixir is installed:

{:yaml_elixir, "~> 2.9"}

After Generation

  1. Review and customize generators in command generator/1 callbacks
  2. Define events/3 (command, status, response) to map responses to your event structs
  3. Add preconditions (when:) and overrides (with:) in the Model's commands()
  4. Implement simulate/2 in the Model for expected events
  5. Configure authentication in the adapter
  6. Add invariants/projections to the model

Summary

Types

Extracted metadata about the API.

A normalized security scheme extracted from the spec.

A normalized event derived from a response schema.

An inferred field type from a JSON schema.

A normalized operation extracted from the spec.

A parsed OpenAPI specification document.

Types

api_info()

@type api_info() :: %{
  title: String.t(),
  version: String.t(),
  description: String.t(),
  base_url: String.t()
}

Extracted metadata about the API.

auth_scheme()

@type auth_scheme() :: %{
  name: String.t(),
  type: String.t() | nil,
  scheme: String.t() | nil,
  bearer_format: String.t() | nil,
  in: String.t() | nil,
  param_name: String.t() | nil,
  description: String.t()
}

A normalized security scheme extracted from the spec.

event()

@type event() :: %{
  name: String.t(),
  fields: [map()],
  description: String.t(),
  operation: String.t() | nil
}

A normalized event derived from a response schema.

field_type()

@type field_type() ::
  :uuid
  | :datetime
  | :date
  | :email
  | :uri
  | :string
  | :integer
  | :number
  | :boolean
  | :map
  | :any
  | {:enum, [term()]}
  | {:string, integer(), integer()}
  | {:integer, integer(), integer()}
  | {:number, number(), number()}
  | {:array, field_type()}
  | {:pattern, String.t()}

An inferred field type from a JSON schema.

operation()

@type operation() :: %{
  operation_id: String.t() | nil,
  module_name: String.t(),
  method: String.t(),
  method_atom: atom(),
  path: String.t(),
  summary: String.t(),
  description: String.t(),
  parameters: [map()],
  request_body: map() | nil,
  responses: %{optional(String.t()) => map()},
  tags: [String.t()],
  security: [map()]
}

A normalized operation extracted from the spec.

spec()

@type spec() :: map()

A parsed OpenAPI specification document.