8000 GitHub - dannote/json_spec: Elixir typespec syntax → JSON Schema, at compile time · GitHub
[go: up one dir, main page]

Skip to content

dannote/json_spec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JSONSpec

Hex.pm Docs

Elixir typespec syntax → JSON Schema, at compile time.

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

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

def deps do
  [{:json_spec, "~> 1.1"
8000
}]
end

Usage

Objects

Keyword-style keys are required by default:

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

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

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

Or use | nil to mark a field as optional:

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

Descriptions

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

Enums

Unions of atoms become "enum":

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

Arrays

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

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

Nesting

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

Atomizing

JSON data uses string keys. atomize/2 converts them back to atoms using the schema as the source of truth:

my_schema = schema(%{
  required(:name) => String.t(),
  required(:status) => :active | :inactive,
  optional(:age) => integer()
})

JSONSpec.atomize(my_schema, %{"name" => "Alice", "status" => "active", "age" => 30})
# => %{name: "Alice", status: :active, age: 30}

Enum string values are converted to atoms. Nested objects and arrays of objects are atomized recursively.

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)

Validate with JSV

Pair with JSV to validate incoming data against your schemas at runtime:

import JSONSpec

@user_schema schema(%{
  required(:name) => String.t(),
  required(:email) => String.t(),
  optional(:role) => :admin | :editor | :viewer
})

root = JSV.build!(@user_schema)

case JSV.validate(params, root) do
  {:ok, data} -> create_user(data)
  {:error, error} -> {:error, JSV.normalize_error(error)}
end

API contract testing

Use schemas in ExUnit tests to verify your API responses match the contract:

@user_response schema(%{
  required(:id) => integer(),
  required(:name) => String.t(),
  required(:email) => String.t(),
  optional(:avatar_url) => String.t()
})

test "GET /api/users/:id returns a valid user" do
  root = JSV.build!(@user_response)
  conn = get(conn, ~p"/api/users/1")
  assert {:ok, _} = JSV.validate(json_response(conn, 200), root)
end

Webhook payload validation

Document and validate outgoing webhook payloads:

@webhook_schema schema(%{
  required(:event) => :order_created | :order_updated | :order_cancelled,
  required(:timestamp) => String.t(),
  required(:data) => %{
    required(:order_id) => integer(),
    required(:total) => number(),
    optional(:items) => [%{name: String.t(), quantity: pos_integer()}]
  }
})

root = JSV.build!(@webhook_schema)

def deliver_webhook(payload) do
  {:ok, _} = JSV.validate(payload, root)
  WebhookClient.post(payload)
end

Use with ReqLLM

JSONSpec works with ReqLLM tool calling. Define the schema, then atomize the args the LLM sends back:

import JSONSpec

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

ReqLLM.tool(
  name: "get_weather",
  description: "Get current weather for a location",
  parameter_schema: @weather_schema,
  callback: fn args ->
    %{location: location, units: units} = JSONSpec.atomize(@weather_schema, args)
    WeatherService.get(location, units || :celsius)
  end
)

License

MIT

About

Elixir typespec syntax → JSON Schema, at compile time

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages

0