Commit cc71f96

mo khan <mo@mokhan.ca>
2026-08-04 13:46:17
refactor: tidy up the CLI option and validation wiring
cli
1 parent 16f2c4e
lib/scim/kit/cli/app.rb
@@ -47,31 +47,33 @@ module Scim
         def fetch_discovery
           discovery = Discovery.new(client)
           result = discovery.fetch
-          return reporter.report(result) unless result.ok? && options[:validate]
+          return reporter.report(result) unless result.ok? && settings.validate?
 
           reporter.report_validation(result, discovery.errors_for(result.body))
         end
 
         def fetch_list(resource_type)
-          endpoint = resolve_endpoint(resource_type)
-          result = client.fetch(endpoint, query: settings.list_query)
-          validate_and_report(result, resource_type) do |schema|
+          entry = resource_type_entry(resource_type)
+          result = client.fetch(
+            endpoint_for(entry), query: settings.list_query
+          )
+          validate_and_report(result, entry) do |schema|
             SchemaRegistry.list_response_with_items(schema)
           end
         end
 
         def fetch_resource(resource_type, id)
-          endpoint = resolve_endpoint(resource_type)
-          path = "#{endpoint}/#{URI.encode_uri_component(id)}"
-          result = client.fetch(
-            path, query: { 'attributes' => options[:attributes] }
-          )
-          validate_and_report(result, resource_type)
+          entry = resource_type_entry(resource_type)
+          path = "#{endpoint_for(entry)}/#{URI.encode_uri_component(id)}"
+          result = client.fetch(path, query: settings.resource_query)
+          validate_and_report(result, entry)
         end
 
-        def resolve_endpoint(resource_type)
-          endpoint = resource_type_entry(resource_type)[:endpoint]
-          raise MissingEndpoint, resource_type if endpoint.to_s.empty?
+        def endpoint_for(entry)
+          endpoint = entry[:endpoint]
+          if endpoint.to_s.empty?
+            raise MissingEndpoint, entry[:name] || entry[:id]
+          end
 
           endpoint.delete_prefix('/')
         end
@@ -88,11 +90,12 @@ module Scim
           @resource_schema_resolver ||= ResourceSchemaResolver.new(client)
         end
 
-        def validate_and_report(result, resource_type, &transform)
-          return reporter.report(result) unless result.ok? && options[:validate]
+        def validate_and_report(result, entry, &transform)
+          return reporter.report(result) unless result.ok? && settings.validate?
 
-          entry = resource_type_entry(resource_type)
-          errors = validation.errors_for(entry, result.body, &transform)
+          errors = validation.errors_for(
+            entry, result.body, sparse: settings.sparse?, &transform
+          )
           return reporter.report_unvalidated(result) unless errors
 
           reporter.report_validation(result, errors)
@@ -100,7 +103,7 @@ module Scim
 
         def validation
           @validation ||= ResourceValidation.new(
-            resource_schema_resolver, reporter, sparse: !options[:attributes].nil?
+            resource_schema_resolver, reporter
           )
         end
 
lib/scim/kit/cli/resource_validation.rb
@@ -4,22 +4,21 @@ module Scim
   module Kit
     module Cli
       class ResourceValidation
-        def initialize(resolver, reporter, sparse: false)
+        def initialize(resolver, reporter)
           @resolver = resolver
           @reporter = reporter
-          @sparse = sparse
         end
 
-        def errors_for(entry, body, &transform)
+        def errors_for(entry, body, sparse: false, &transform)
           schema = schema_for(entry)
           return unless schema
 
-          Validator.errors_for(prepare(schema, &transform), body)
+          Validator.errors_for(prepare(schema, sparse, &transform), body)
         end
 
         private
 
-        attr_reader :resolver, :reporter, :sparse
+        attr_reader :resolver, :reporter
 
         def schema_for(entry)
           schema = resolver.schema_for(entry)
@@ -27,7 +26,7 @@ module Scim
           schema
         end
 
-        def prepare(schema)
+        def prepare(schema, sparse)
           schema = SparseSchema.relax(schema) if sparse
           block_given? ? yield(schema) : schema
         end
lib/scim/kit/cli/settings.rb
@@ -30,6 +30,18 @@ module Scim
           QUERY.transform_values { |name| options[name] }
         end
 
+        def resource_query
+          list_query.slice('attributes')
+        end
+
+        def validate?
+          options[:validate]
+        end
+
+        def sparse?
+          !options[:attributes].nil?
+        end
+
         private
 
         attr_reader :options, :env
lib/scim/kit/cli.rb
@@ -11,8 +11,8 @@ require 'scim/kit/cli/resource_schema_resolver'
 require 'scim/kit/cli/resource_type_resolver'
 require 'scim/kit/cli/resource_validation'
 require 'scim/kit/cli/schema_registry'
-require 'scim/kit/cli/settings'
 require 'scim/kit/cli/scim_schema_converter'
+require 'scim/kit/cli/settings'
 require 'scim/kit/cli/sparse_schema'
 require 'scim/kit/cli/validator'
 
spec/scim/kit/cli/discovery_spec.rb
@@ -52,10 +52,10 @@ RSpec.describe Scim::Kit::Cli::Discovery do
 
   describe '#errors_for' do
     it 'is empty when every document conforms' do
-      documents = subject.fetch.body
-      documents[:service_provider_configuration] = valid_config
-      documents[:schemas] = valid_list
-      documents[:resource_types] = valid_list
+      documents = {
+        service_provider_configuration: valid_config,
+        schemas: valid_list, resource_types: valid_list
+      }
 
       expect(subject.errors_for(documents)).to eql({})
     end
spec/scim/kit/cli/resource_validation_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ResourceValidation do
+  subject { described_class.new(resolver, reporter) }
+
+  let(:reporter) { Scim::Kit::Cli::Reporter.new(Thor::Shell::Basic.new) }
+  let(:entry) { { name: 'User', schema: core_urn } }
+  let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+  let(:schema) do
+    {
+      'type' => 'object',
+      'properties' => { 'userName' => { 'type' => 'string' } },
+      'required' => %w[schemas id userName]
+    }
+  end
+  let(:resolver) do
+    instance_double(
+      Scim::Kit::Cli::ResourceSchemaResolver,
+      schema_for: schema, undeclared_extensions: []
+    )
+  end
+  let(:resource) { { schemas: [core_urn], id: '1', userName: 'mo' } }
+
+  describe '#errors_for' do
+    it 'is empty for a conforming resource' do
+      expect(subject.errors_for(entry, resource)).to eql([])
+    end
+
+    it 'reports a schema violation' do
+      expect(subject.errors_for(entry, resource.merge(userName: 42)))
+        .to include(/userName.*is not of type: string/)
+    end
+
+    it 'applies the transform block to the schema' do
+      errors = subject.errors_for(entry, { totalResults: 1 }) do |resource_schema|
+        Scim::Kit::Cli::SchemaRegistry.list_response_with_items(resource_schema)
+      end
+
+      expect(errors).to include(/missing required keys.*schemas/)
+    end
+
+    context 'when sparse is set' do
+      it 'does not demand attributes the server was not asked for' do
+        errors = subject.errors_for(
+          entry, { schemas: [core_urn], id: '1' }, sparse: true
+        )
+
+        expect(errors).to eql([])
+      end
+
+      it 'still demands the always-returned attributes' do
+        expect(subject.errors_for(entry, { userName: 'mo' }, sparse: true))
+          .to include(/missing required keys/)
+      end
+    end
+
+    context 'when the schema cannot be resolved' do
+      let(:resolver) do
+        instance_double(
+          Scim::Kit::Cli::ResourceSchemaResolver, schema_for: nil
+        )
+      end
+
+      it 'returns nil so the caller can flag it as unvalidated' do
+        allow($stderr).to receive(:print)
+
+        expect(subject.errors_for(entry, resource)).to be_nil
+      end
+
+      it 'warns naming the resource type' do
+        expect { subject.errors_for(entry, resource) }
+          .to output(/no schema found for resource type "User"/).to_stderr
+      end
+    end
+
+    context 'when the resource type declares an undeclared extension' do
+      let(:resolver) do
+        instance_double(
+          Scim::Kit::Cli::ResourceSchemaResolver,
+          schema_for: schema, undeclared_extensions: ['urn:vendor:2.0:Thing']
+        )
+      end
+
+      it 'warns naming the extension urn' do
+        expect { subject.errors_for(entry, resource) }
+          .to output(/urn:vendor:2.0:Thing/).to_stderr
+      end
+
+      it 'still validates the resource' do
+        allow($stderr).to receive(:print)
+
+        expect(subject.errors_for(entry, resource)).to eql([])
+      end
+    end
+  end
+end