Skip to content

Strict Output Validator — Ruby source

Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Strict Output Validator — validate a JSON Schema against OpenAI's
# structured-outputs strict mode rules, so a schema fails HERE instead of
# at the API.
#
# Language: Ruby (3.x, stdlib JSON only)
# Port of src/lib/strictOutputValidator.ts (the canonical TypeScript
# implementation).
# Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
#
# Rules (2026 OpenAI strict mode):
#   R1 root must be type "object"          (validate_strict_root)
#   R2 every object node needs additionalProperties: false
#   R3 every key in properties must be in required
#   R4 required must not name keys absent from properties
#   R5 only supported type values / keywords may appear

require 'json'

module StrictOutputValidator
  # Types strict mode supports.
  SUPPORTED_TYPES = %w[object array string number integer boolean].freeze

  # Keywords strict mode understands per-node. Everything else is flagged.
  SUPPORTED_KEYWORDS = %w[
    type description title properties required additionalProperties items
    enum const anyOf allOf $ref $defs definitions format nullable default
  ].freeze

  Issue = Struct.new(:path, :rule, :message, keyword_init: true)

  Report = Struct.new(
    :ok, :issues, :objects, :properties, :enums, keyword_init: true
  )

  module_function

  # validateStrictSchema: walk the parsed schema (a Hash from JSON.parse)
  # and collect rule issues.
  def validate_strict_schema(schema)
    issues = []
    counts = { objects: 0, properties: 0, enums: 0 }

    unless schema.is_a?(Hash)
      issues << Issue.new(
        path: '$', rule: 'invalid-schema', message: 'Schema must be a JSON object.'
      )
      return Report.new(ok: false, issues: issues, objects: 0, properties: 0, enums: 0)
    end

    walk = lambda do |node, path|
      # R5 keywords
      node.each_key do |key|
        next if SUPPORTED_KEYWORDS.include?(key)

        issues << Issue.new(
          path: path, rule: 'unsupported-keyword',
          message: "\"#{key}\" is not supported in strict mode — remove it or express " \
                   'the constraint another way.'
        )
      end

      # R5 types ('null' only inside a type array).
      type = node['type']
      case type
      when String
        unless SUPPORTED_TYPES.include?(type)
          issues << Issue.new(
            path: path, rule: 'unsupported-type',
            message: "type \"#{type}\" is not supported — strict mode allows object, " \
                     'array, string, number, integer, boolean (null only inside a type array).'
          )
        end
      when Array
        type.each do |t|
          ok = t.is_a?(String) && (SUPPORTED_TYPES.include?(t) || t == 'null')
          next if ok

          issues << Issue.new(
            path: path, rule: 'unsupported-type',
            message: 'a type-array entry is not supported — strict mode allows object, ' \
                     'array, string, number, integer, boolean, null.'
          )
        end
      end

      # allOf is accepted only as a single-element wrapper.
      all_of = node['allOf']
      if all_of.is_a?(Array) && all_of.length != 1
        issues << Issue.new(
          path: path, rule: 'unsupported-keyword',
          message: 'allOf is supported only with exactly one subschema (use anyOf for unions).'
        )
      end

      # Object node: R2, R3, R4.
      is_object_node = type == 'object' || node.key?('properties') || node.key?('required')
      if is_object_node
        counts[:objects] += 1
        ap = node['additionalProperties']
        ap_false = ap.equal?(false)
        unless ap_false
          issues << Issue.new(
            path: path, rule: 'missing-additional-properties',
            message: 'Object needs "additionalProperties": false — strict mode rejects ' \
                     'open objects.'
          )
        end
        props = node['properties'].is_a?(Hash) ? node['properties'] : nil
        required = node['required'].is_a?(Array) ? node['required'] : nil
        req_keys = required.to_a.grep(String)
        if props
          counts[:properties] += props.length
          props.each_key do |key|
            next if req_keys.include?(key)

            issues << Issue.new(
              path: "#{path}.required", rule: 'property-not-required',
              message: "\"#{key}\" is defined in properties but missing from required — " \
                       'strict mode requires every property.'
            )
          end
          req_keys.each do |key|
            next if props.key?(key)

            issues << Issue.new(
              path: "#{path}.required", rule: 'required-not-property',
              message: "\"#{key}\" is required but has no definition in properties."
            )
          end
          props.each do |key, sub|
            walk.call(sub, "#{path}.properties.#{key}") if sub.is_a?(Hash)
          end
        else
          req_keys.each do |key|
            issues << Issue.new(
              path: "#{path}.required", rule: 'required-not-property',
              message: "\"#{key}\" is required but has no definition in properties."
            )
          end
        end
      end

      walk.call(node['items'], "#{path}.items") if node['items'].is_a?(Hash)
      counts[:enums] += 1 if node['enum'].is_a?(Array)

      %w[anyOf oneOf allOf].each do |list_key|
        list = node[list_key]
        next unless list.is_a?(Array)

        if list_key == 'oneOf'
          issues << Issue.new(
            path: "#{path}.#{list_key}", rule: 'unsupported-keyword',
            message: 'oneOf is not supported — strict mode unions are expressed with anyOf.'
          )
        end
        list.each_with_index do |sub, i|
          walk.call(sub, "#{path}.#{list_key}[#{i}]") if sub.is_a?(Hash)
        end
      end
      %w[$defs definitions].each do |defs_key|
        defs = node[defs_key]
        next unless defs.is_a?(Hash)

        defs.each do |name, sub|
          walk.call(sub, "#{path}.#{defs_key}.#{name}") if sub.is_a?(Hash)
        end
      end
    end

    walk.call(schema, '$')
    Report.new(
      ok: issues.empty?, issues: issues,
      objects: counts[:objects], properties: counts[:properties], enums: counts[:enums]
    )
  end

  # The whole-report entry point: parse + validate + R1 (root must be
  # type "object"). `input` is a JSON string.
  def validate_strict_root(input)
    schema = begin
      input.is_a?(String) ? JSON.parse(input) : input
    rescue JSON::ParserError => e
      return Report.new(
        ok: false,
        issues: [Issue.new(path: '$', rule: 'invalid-schema',
                           message: "Not valid JSON: #{e.message}")],
        objects: 0, properties: 0, enums: 0
      )
    end
    report = validate_strict_schema(schema)
    if schema.is_a?(Hash) && schema['type'] != 'object'
      report.issues.unshift(
        Issue.new(
          path: '$', rule: 'root-not-object',
          message: 'The root schema must be type "object" — strict mode cannot return ' \
                   'a bare scalar or array.'
        )
      )
      report.ok = false
    end
    report
  end
end

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →