lib/openagents/tools/schema.ex

main at 58e6347eeb72 · 4 KB

defmodule OpenAgents.Tools.Schema do
  @moduledoc false

  @maximum_depth 8
  @maximum_properties 64

  @spec validate_schema(term()) :: :ok | {:error, atom()}
  def validate_schema(schema), do: validate_schema(schema, 0)

  @spec validate_value(term(), map()) :: :ok | {:error, atom()}
  def validate_value(value, schema), do: validate_value(value, schema, 0)

  defp validate_schema(_schema, depth) when depth > @maximum_depth,
    do: {:error, :schema_too_deep}

  defp validate_schema(%{"type" => "object", "properties" => properties} = schema, depth)
       when is_map(properties) and map_size(properties) <= @maximum_properties do
    required = Map.get(schema, "required", [])

    cond do
      Map.get(schema, "additionalProperties", false) not in [true, false] ->
        {:error, :invalid_schema}

      not is_list(required) or not Enum.all?(required, &is_binary/1) ->
        {:error, :invalid_schema}

      not Enum.all?(required, &Map.has_key?(properties, &1)) ->
        {:error, :invalid_schema}

      true ->
        validate_child_schemas(properties, depth)
    end
  end

  defp validate_schema(%{"type" => type} = schema, _depth)
       when type in ["string", "integer", "number", "boolean", "null"] do
    validate_scalar_constraints(type, schema)
  end

  defp validate_schema(%{"type" => "array", "items" => items} = schema, depth) do
    with :ok <- validate_non_negative(schema, "maxItems"),
         :ok <- validate_schema(items, depth + 1) do
      :ok
    end
  end

  defp validate_schema(_schema, _depth), do: {:error, :invalid_schema}

  defp validate_child_schemas(properties, depth) do
    Enum.reduce_while(properties, :ok, fn
      {key, child_schema}, :ok when is_binary(key) and byte_size(key) in 1..128 ->
        case validate_schema(child_schema, depth + 1) do
          :ok -> {:cont, :ok}
          error -> {:halt, error}
        end

      _property, :ok ->
        {:halt, {:error, :invalid_schema}}
    end)
  end

  defp validate_scalar_constraints("string", schema),
    do: validate_non_negative(schema, "maxLength")

  defp validate_scalar_constraints(_type, _schema), do: :ok

  defp validate_non_negative(schema, key) do
    case Map.get(schema, key) do
      nil -> :ok
      value when is_integer(value) and value >= 0 -> :ok
      _value -> {:error, :invalid_schema}
    end
  end

  defp validate_value(_value, _schema, depth) when depth > @maximum_depth,
    do: {:error, :value_too_deep}

  defp validate_value(value, %{"type" => "object", "properties" => properties} = schema, depth)
       when is_map(value) do
    required = Map.get(schema, "required", [])
    additional? = Map.get(schema, "additionalProperties", false)
    keys = Map.keys(value)

    cond do
      not Enum.all?(required, &Map.has_key?(value, &1)) ->
        {:error, :required_property_missing}

      not additional? and not Enum.all?(keys, &Map.has_key?(properties, &1)) ->
        {:error, :additional_property_not_allowed}

      true ->
        Enum.reduce_while(value, :ok, fn {key, child_value}, :ok ->
          case Map.fetch(properties, key) do
            {:ok, child_schema} ->
              case validate_value(child_value, child_schema, depth + 1) do
                :ok -> {:cont, :ok}
                error -> {:halt, error}
              end

            :error when additional? ->
              {:cont, :ok}

            :error ->
              {:halt, {:error, :additional_property_not_allowed}}
          end
        end)
    end
  end

  defp validate_value(value, %{"type" => "array"} = schema, depth) when is_list(value) do
    maximum = Map.get(schema, "maxItems")

    if is_integer(maximum) and length(value) > maximum do
      {:error, :array_too_large}
    else
      Enum.reduce_while(value, :ok, fn child, :ok ->
        case validate_value(child, schema["items"], depth + 1) do
          :ok -> {:cont, :ok}
          error -> {:halt, error}
        end
      end)
    end
  end

  defp validate_value(value, %{"type" => "string"} = schema, _depth) when is_binary(value) do
    maximum = Map.get(schema, "maxLength")

    if is_integer(maximum) and String.length(value) > maximum,
      do: {:error, :string_too_long},
      else: :ok
  end

  defp validate_value(value, %{"type" => "integer"}, _depth) when is_integer(value), do: :ok
  defp validate_value(value, %{"type" => "number"}, _depth) when is_number(value), do: :ok
  defp validate_value(value, %{"type" => "boolean"}, _depth) when is_boolean(value), do: :ok
  defp validate_value(nil, %{"type" => "null"}, _depth), do: :ok
  defp validate_value(_value, _schema, _depth), do: {:error, :type_mismatch}
end