Packages

A User-Friendly Component-Based Cardano SDK for Elixir.

Current section

Files

Jump to
sutra_cardano lib sutra cardano blueprint parser.ex
Raw

lib/sutra/cardano/blueprint/parser.ex

defmodule Sutra.Cardano.Blueprint.Parser do
@moduledoc """
Parser for CIP-57 Blueprint schemas that resolves all `$ref` references recursively.
This module provides functionality to:
- Parse a blueprint JSON file and extract validator schemas
- Resolve all `$ref` references recursively to produce a self-contained schema
- The resolved schema can be used directly with `Blueprint.encode/2` and `Blueprint.decode/2`
without needing to pass definitions separately
## Example
# Load and parse a blueprint
{:ok, blueprint_json} = File.read("plutus.json")
{:ok, blueprint} = Jason.decode(blueprint_json)
# Resolve a redeemer schema
redeemer_schema = %{"$ref" => "#/definitions/nuvola/types/LendingRedeemer"}
{:ok, resolved} = Parser.resolve_schema(redeemer_schema, blueprint["definitions"])
# Now encode/decode without passing definitions
{:ok, plutus_data} = Blueprint.encode(%{constructor: "CreateLend"}, resolved)
{:ok, decoded} = Blueprint.decode(plutus_data, resolved)
## Extracting Validator Schemas
# Parse validators from blueprint
{:ok, validators} = Parser.parse_validators(blueprint)
# Each validator has resolved datum and redeemer schemas
%{
title: "lending.lending_validator.spend",
datum_schema: %{...}, # Fully resolved, no $refs
redeemer_schema: %{...}, # Fully resolved, no $refs
parameters: [...]
}
"""
@type definitions :: %{String.t() => map()}
@type schema :: map()
@type validator_info :: %{
title: String.t(),
datum_schema: schema() | nil,
redeemer_schema: schema() | nil,
parameters: [map()],
compiled_code: String.t() | nil,
hash: String.t() | nil
}
# ============================================================================
# Public API
# ============================================================================
@doc """
Parses a blueprint and extracts all validators with their resolved schemas.
## Parameters
- `blueprint` - The parsed blueprint JSON (as a map)
## Returns
- `{:ok, validators}` - List of validator info maps with resolved schemas
- `{:error, reason}` - Parsing failed
## Example
{:ok, blueprint} = Jason.decode(json)
{:ok, validators} = Parser.parse_validators(blueprint)
Enum.each(validators, fn v ->
IO.puts("Validator: \#{v.title}")
end)
"""
@spec parse_validators(map()) :: {:ok, [validator_info()]} | {:error, any()}
def parse_validators(%{"validators" => validators, "definitions" => definitions}) do
parsed =
Enum.map(validators, fn validator ->
parse_validator(validator, definitions)
end)
errors = Enum.filter(parsed, &match?({:error, _}, &1))
if errors == [] do
{:ok, Enum.map(parsed, fn {:ok, v} -> v end)}
else
{:error, {:parse_errors, errors}}
end
end
def parse_validators(%{"validators" => validators}) do
parse_validators(%{"validators" => validators, "definitions" => %{}})
end
def parse_validators(_) do
{:error, :invalid_blueprint_format}
end
@doc """
Parses a single validator and resolves its datum and redeemer schemas.
## Parameters
- `validator` - A validator definition from the blueprint
- `definitions` - The definitions map from the blueprint
## Returns
- `{:ok, validator_info}` - Parsed validator with resolved schemas
- `{:error, reason}` - Parsing failed
"""
@spec parse_validator(map(), definitions()) :: {:ok, validator_info()} | {:error, any()}
def parse_validator(validator, definitions) do
title = validator["title"]
datum_result =
case validator["datum"] do
nil -> {:ok, nil}
%{"schema" => schema} -> resolve_schema(schema, definitions)
schema -> resolve_schema(schema, definitions)
end
redeemer_result =
case validator["redeemer"] do
nil -> {:ok, nil}
%{"schema" => schema} -> resolve_schema(schema, definitions)
schema -> resolve_schema(schema, definitions)
end
parameters_result = resolve_parameters(validator["parameters"] || [], definitions)
with {:ok, datum_schema} <- datum_result,
{:ok, redeemer_schema} <- redeemer_result,
{:ok, parameters} <- parameters_result do
{:ok,
%{
title: title,
datum_schema: datum_schema,
redeemer_schema: redeemer_schema,
parameters: parameters,
compiled_code: validator["compiledCode"],
hash: validator["hash"]
}}
end
end
@doc """
Resolves all `$ref` references in a schema recursively.
This function traverses the schema and replaces all `$ref` references with their
actual definitions, producing a self-contained schema that can be used for
encoding/decoding without needing the definitions map.
## Parameters
- `schema` - The schema to resolve (may contain `$ref` references)
- `definitions` - The definitions map from the blueprint
## Returns
- `{:ok, resolved_schema}` - Schema with all refs resolved
- `{:error, reason}` - Resolution failed (e.g., missing definition)
## Example
definitions = %{
"Int" => %{"dataType" => "integer"},
"MyType" => %{
"anyOf" => [
%{"title" => "Foo", "dataType" => "constructor", "index" => 0, "fields" => [
%{"$ref" => "#/definitions/Int"}
]}
]
}
}
schema = %{"$ref" => "#/definitions/MyType"}
{:ok, resolved} = Parser.resolve_schema(schema, definitions)
# resolved is now:
# %{
# "anyOf" => [
# %{"title" => "Foo", "dataType" => "constructor", "index" => 0, "fields" => [
# %{"dataType" => "integer"}
# ]}
# ]
# }
"""
@spec resolve_schema(schema(), definitions()) :: {:ok, schema()} | {:error, any()}
def resolve_schema(schema, definitions) do
resolve_schema(schema, definitions, MapSet.new())
end
# ============================================================================
# Private Implementation
# ============================================================================
# Resolve with cycle detection to prevent infinite loops
defp resolve_schema(schema, definitions, visited)
# Handle $ref - resolve and continue recursively
defp resolve_schema(%{"$ref" => ref} = schema, definitions, visited) do
if MapSet.member?(visited, ref) do
# Return the ref as-is to break the cycle (this is valid for recursive types)
{:ok, schema}
else
case lookup_definition(ref, definitions) do
{:ok, resolved_def} ->
new_visited = MapSet.put(visited, ref)
resolve_schema(resolved_def, definitions, new_visited)
error ->
error
end
end
end
# Handle anyOf (constructors/enums)
defp resolve_schema(%{"anyOf" => variants} = schema, definitions, visited) do
case resolve_variants(variants, definitions, visited) do
{:ok, resolved_variants} ->
{:ok, Map.put(schema, "anyOf", resolved_variants)}
error ->
error
end
end
# Handle list with single item schema
defp resolve_schema(%{"dataType" => "list", "items" => items} = schema, definitions, visited)
when is_map(items) do
case resolve_schema(items, definitions, visited) do
{:ok, resolved_items} ->
{:ok, Map.put(schema, "items", resolved_items)}
error ->
error
end
end
# Handle tuple (list with array of item schemas)
defp resolve_schema(%{"dataType" => "list", "items" => items} = schema, definitions, visited)
when is_list(items) do
case resolve_list(items, definitions, visited) do
{:ok, resolved_items} ->
{:ok, Map.put(schema, "items", resolved_items)}
error ->
error
end
end
# Handle map type
defp resolve_schema(
%{"dataType" => "map", "keys" => keys, "values" => values} = schema,
definitions,
visited
) do
with {:ok, resolved_keys} <- resolve_schema(keys, definitions, visited),
{:ok, resolved_values} <- resolve_schema(values, definitions, visited) do
{:ok, schema |> Map.put("keys", resolved_keys) |> Map.put("values", resolved_values)}
end
end
# Handle constructor with fields
defp resolve_schema(
%{"dataType" => "constructor", "fields" => fields} = schema,
definitions,
visited
)
when is_list(fields) do
case resolve_fields(fields, definitions, visited) do
{:ok, resolved_fields} ->
{:ok, Map.put(schema, "fields", resolved_fields)}
error ->
error
end
end
# Handle primitive types and other schemas (no resolution needed)
defp resolve_schema(schema, _definitions, _visited) when is_map(schema) do
{:ok, schema}
end
# Handle nil
defp resolve_schema(nil, _definitions, _visited) do
{:ok, nil}
end
# Resolve list of variants (for anyOf)
defp resolve_variants(variants, definitions, visited) do
results =
Enum.reduce_while(variants, {:ok, []}, fn variant, {:ok, acc} ->
case resolve_schema(variant, definitions, visited) do
{:ok, resolved} -> {:cont, {:ok, [resolved | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, resolved} -> {:ok, Enum.reverse(resolved)}
error -> error
end
end
# Resolve list of field schemas
defp resolve_fields(fields, definitions, visited) do
results =
Enum.reduce_while(fields, {:ok, []}, fn field, {:ok, acc} ->
case resolve_field(field, definitions, visited) do
{:ok, resolved} -> {:cont, {:ok, [resolved | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, resolved} -> {:ok, Enum.reverse(resolved)}
error -> error
end
end
defp resolve_field(%{"$ref" => _} = field, definitions, visited) do
case resolve_schema(field, definitions, visited) do
{:ok, resolved} ->
# Preserve title if present in original field
resolved =
if field["title"], do: Map.put(resolved, "title", field["title"]), else: resolved
{:ok, resolved}
error ->
error
end
end
defp resolve_field(field, definitions, visited) do
resolve_schema(field, definitions, visited)
end
# Resolve list of schemas (for tuple items)
defp resolve_list(items, definitions, visited) do
results =
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} ->
case resolve_schema(item, definitions, visited) do
{:ok, resolved} -> {:cont, {:ok, [resolved | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, resolved} -> {:ok, Enum.reverse(resolved)}
error -> error
end
end
# Resolve parameters
defp resolve_parameters(parameters, definitions) do
results =
Enum.reduce_while(parameters, {:ok, []}, fn param, {:ok, acc} ->
case resolve_parameter(param, definitions) do
{:ok, resolved} -> {:cont, {:ok, [resolved | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, resolved} -> {:ok, Enum.reverse(resolved)}
error -> error
end
end
defp resolve_parameter(%{"schema" => schema} = param, definitions) do
case resolve_schema(schema, definitions, MapSet.new()) do
{:ok, resolved} -> {:ok, Map.put(param, "schema", resolved)}
error -> error
end
end
defp resolve_parameter(param, _definitions), do: {:ok, param}
# Look up a definition by reference path
defp lookup_definition("#/definitions/" <> path, definitions) do
# Handle URL-encoded paths (e.g., "cardano~1assets~1PolicyId" -> "cardano/assets/PolicyId")
decoded_path = URI.decode(path) |> String.replace("~1", "/")
case Map.get(definitions, decoded_path) || Map.get(definitions, path) do
nil -> {:error, {:definition_not_found, path}}
schema -> {:ok, schema}
end
end
defp lookup_definition(ref, _definitions) do
{:error, {:invalid_ref_format, ref}}
end
end