README.md

# JSONSpec

[![Hex.pm](https://img.shields.io/hexpm/v/json_spec.svg)](https://hex.pm/packages/json_spec)
[![Docs](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/json_spec)

Elixir typespec syntax → JSON Schema, at compile time.

Write familiar Elixir types, get a JSON Schema map with zero runtime cost.

```elixir
import JSONSpec

schema(%{
  required(:location) => String.t(),
  optional(:units) => :celsius | :fahrenheit
})

# => %{
#   "type" => "object",
#   "properties" => %{
#     "location" => %{"type" => "string"},
#     "units" => %{"type" => "string", "enum" => ["celsius", "fahrenheit"]}
#   },
#   "required" => ["location"],
#   "additionalProperties" => false
# }
```

## Installation

```elixir
def deps do
  [{:json_spec, "~> 1.0"}]
end
```

## Usage

### Objects

Keyword-style keys are required by default:

```elixir
schema(%{name: String.t(), age: integer()})
# Both "name" and "age" in "required"
```

Use `optional()` / `required()` with arrow syntax for explicit control:

```elixir
schema(%{
  required(:name) => String.t(),
  optional(:email) => String.t()
})
```

Or use `| nil` to mark a field as optional:

```elixir
schema(%{name: String.t(), email: String.t() | nil})
# Only "name" in "required"
```

### Descriptions

```elixir
schema(
  %{required(:location) => String.t(), optional(:units) => :celsius | :fahrenheit},
  doc: [location: "City name", units: "Temperature units"]
)
```

### Enums

Unions of atoms become `"enum"`:

```elixir
schema(:active | :inactive | :pending)
# => %{"type" => "string", "enum" => ["active", "inactive", "pending"]}
```

### Arrays

```elixir
schema([String.t()])
# => %{"type" => "array", "items" => %{"type" => "string"}}

schema([%{id: integer(), name: String.t()}])
# Array of objects
```

### Nesting

```elixir
schema(%{
  user: %{
    name: String.t(),
    address: %{city: String.t(), zip: String.t()}
  }
})
```

## Type mapping

| Elixir | JSON Schema |
|---|---|
| `String.t()` | `{"type": "string"}` |
| `binary()` | `{"type": "string"}` |
| `integer()` | `{"type": "integer"}` |
| `pos_integer()` | `{"type": "integer", "minimum": 1}` |
| `non_neg_integer()` | `{"type": "integer", "minimum": 0}` |
| `neg_integer()` | `{"type": "integer", "maximum": -1}` |
| `float()` | `{"type": "number"}` |
| `number()` | `{"type": "number"}` |
| `boolean()` | `{"type": "boolean"}` |
| `map()` | `{"type": "object"}` |
| `atom()` | `{"type": "string"}` |
| `term()` / `any()` | `{}` (no constraints) |
| `:a \| :b \| :c` | `{"enum": ["a", "b", "c"]}` |
| `[type]` | `{"type": "array", "items": ...}` |
| `%{k: type}` | nested object |
| `type \| nil` | optional (not in `required`) |

## Use with LLM tools

JSONSpec pairs well with [ReqLLM](https://github.com/agentjido/req_llm):

```elixir
import JSONSpec

Tool.new!(
  name: "get_weather",
  description: "Get current weather for a location",
  parameter_schema: schema(
    %{
      required(:location) => String.t(),
      optional(:units) => :celsius | :fahrenheit
    },
    doc: [location: "City name", units: "Temperature units"]
  ),
  callback: {WeatherService, :get_current_weather}
)
```

## License

MIT