Verified Commit 0d4f9e95 authored by Hercules Merscher's avatar Hercules Merscher 🌴
Browse files

feat: Using a custom external ref resolver

parent 3c7b553f
Loading
Loading
Loading
Loading
+76 −0
Original line number Diff line number Diff line
# JSON Schema Reference Resolver

## Overview

The `RefResolver` class provides HTTP/HTTPS-based resolution for external JSON schema references. It enables validation of JSON data against schemas that reference remote schema definitions, with built-in caching and timeout protection.

## Why Use RefResolver?

When working with JSON schemas, you may encounter `$ref` properties that point to external schema definitions hosted remotely:

```json
{
  "$ref": "https://example.com/schemas/common.json#/definitions/address"
}
```

The `RefResolver` solves several key challenges:

- **Remote Schema Resolution**: Automatically fetches and parses external schema definitions over HTTP/HTTPS
- **Performance Optimization**: Caches fetched schemas to avoid redundant network requests
- **Reliability**: Implements configurable timeouts to prevent hanging on slow or unresponsive endpoints
- **Security**: Uses SSL certificate validation with proper certificate store configuration
- **Error Handling**: Provides clear error messages for network failures, timeouts, and invalid JSON responses

## How to Use

### Basic Usage

The `RefResolver` is designed to work with the [JSONSchemer](https://github.com/davishmcclurg/json_schemer) gem:

```ruby
require 'labkit/json_schema/ref_resolver'

# Create a resolver instance
resolver = Labkit::JsonSchema::RefResolver.new

# Use with JSONSchemer
schema = JSONSchemer.schema(
  your_schema_hash,
  ref_resolver: resolver
)

# Validate data
schema.valid?(your_data)
```

### Custom Timeout Configuration

By default, the resolver uses a 2-second timeout for both connection and read operations. You can customize this:

```ruby
# Use a 5-second timeout
resolver = Labkit::JsonSchema::RefResolver.new(timeout_s: 5)
```

### Cache Management

The resolver maintains a class-level cache to share fetched schemas across instances:

```ruby
# Access the cache
Labkit::JsonSchema::RefResolver.cache

# Clear the cache if needed
Labkit::JsonSchema::RefResolver.cache.clear
```

## Use Case Example

The `RefResolver` is used in the User Experience SLI Registry [lib/labkit/user_experience_sli/registry.rb](lib/labkit/user_experience_sli/registry.rb) to validate user experience definitions against a JSON schema.

## Implementation Details

- Only HTTP and HTTPS schemes are supported. Other schemes (FTP, file://, etc.) will raise an error.
- The resolver configures SSL certificate validation using the system's default certificate store with the `PARTIAL_CHAIN` flag, which validates certificate chains and expiration while being more lenient with certain certificate configurations.
- The class-level cache is shared across all instances. In multi-threaded environments, consider using appropriate synchronization mechanisms if cache consistency is critical.
+75 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'net/http'
require 'uri'
require 'json'

module Labkit
  module JsonSchema
    # This class resolves JSON schema references (e.g., "$ref": "http://example.com/schema.json")
    # by fetching remote schemas over HTTP/HTTPS and caching them.
    # It is used by JSONSchemer to resolve external schema definitions.
    # We need it to validate JSON data against schemas that might be hosted remotely.
    class RefResolver
      mattr_accessor :cache, default: {}

      def initialize(timeout_s: 2)
        @timeout_s = timeout_s
      end

      def call(uri)
        uri_str = uri.to_s

        return cache[uri_str] if cache.key?(uri_str)

        cache[uri_str] = fetch_remote_schema(uri_str)
      end

      private

      def fetch_remote_schema(uri_str)
        uri = URI(uri_str)

        raise(JSONSchemer::UnknownRef, "Unsupported URI scheme: #{uri_str}") unless %w[http https].include?(uri.scheme)

        response = Net::HTTP.start(
          uri.host,
          uri.port,
          use_ssl: uri.scheme == 'https',
          cert_store: ssl_cert_store,
          open_timeout: @timeout_s,
          read_timeout: @timeout_s
        ) do |http|
          request = Net::HTTP::Get.new(uri.request_uri)
          http.request(request)
        end

        unless response.is_a?(Net::HTTPSuccess)
          raise(
            JSONSchemer::UnknownRef,
            "Failed to fetch #{uri_str}: #{response.code} #{response.message}"
          )
        end

        JSON.parse(response.body)
      rescue Net::OpenTimeout, Net::ReadTimeout => e
        raise(JSONSchemer::UnknownRef, "Timeout fetching #{uri_str}: #{e.message}")
      rescue JSON::ParserError => e
        raise(JSONSchemer::UnknownRef, "Invalid JSON at #{uri_str}: #{e.message}")
      rescue SocketError, Errno::ECONNREFUSED => e
        raise(JSONSchemer::UnknownRef, "Connection failed for #{uri_str}: #{e.message}")
      end

      def ssl_cert_store
        store = OpenSSL::X509::Store.new
        store.set_default_paths
        # This still validates the certificate chain and expiration,
        # just not explicitly revoked certs
        store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
        store
      rescue StandardError
        nil
      end
    end
  end
end
+6 −3
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ require 'forwardable'
require 'json_schemer'
require 'pathname'
require 'yaml'
require 'labkit/json_schema/ref_resolver'

module Labkit
  module UserExperienceSli
@@ -77,8 +78,7 @@ module Labkit
        content = YAML.safe_load(file_path.read)
        return nil unless content.is_a?(Hash)

        schemer = JSONSchemer.schema(schema)
        return Definition.new(user_experience_id: experience_id, **content) if schemer.valid?(content)
        return Definition.new(user_experience_id: experience_id, **content) if schema.valid?(content)

        warn("Invalid schema for #{file_path}")

@@ -90,7 +90,10 @@ module Labkit
      end

      def schema
        @schema ||= JSON.parse(File.read(SCHEMA_PATH))
        @schema ||= begin
          schema = JSON.parse(File.read(SCHEMA_PATH))
          JSONSchemer.schema(schema, ref_resolver: Labkit::JsonSchema::RefResolver.new)
        end
      end

      def warn(message)