Commit aedfd65

mo khan <mo@mokhan.ca>
2026-08-03 19:29:17
refactor: extract collaborators out of the CLI app
cli
1 parent 78367f8
lib/scim/kit/cli/app.rb
@@ -4,12 +4,6 @@ module Scim
   module Kit
     module Cli
       class App < Thor
-        RESOURCES = {
-          service_provider_configuration: 'ServiceProviderConfig',
-          schemas: 'Schemas',
-          resource_types: 'ResourceTypes'
-        }.freeze
-
         def self.exit_on_failure?
           true
         end
@@ -51,33 +45,16 @@ module Scim
         end
 
         def fetch_discovery
-          responses = {}
-          RESOURCES.each do |key, path|
-            result = client.fetch(path)
-            return reporter.report(result) unless result.ok?
-
-            responses[key] = result.body
-          end
-          report_discovery(Http::Result.new(200, responses))
-        end
-
-        def report_discovery(combined)
-          return reporter.report(combined) unless options[:validate]
-
-          reporter.report_validation(combined, discovery_errors(combined.body))
-        end
+          discovery = Discovery.new(client)
+          result = discovery.fetch
+          return reporter.report(result) unless result.ok? && options[:validate]
 
-        def discovery_errors(responses)
-          responses.each_with_object({}) do |(key, body), errors|
-            schema = SchemaRegistry.fetch(key)
-            body_errors = Validator.errors_for(schema, body)
-            errors[key] = body_errors unless body_errors.empty?
-          end
+          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: list_query)
+          result = client.fetch(endpoint, query: settings.list_query)
           validate_and_report(result, resource_type) do |schema|
             SchemaRegistry.list_response_with_items(schema)
           end
@@ -114,86 +91,29 @@ module Scim
         def validate_and_report(result, resource_type, &transform)
           return reporter.report(result) unless result.ok? && options[:validate]
 
-          schema = schema_for(resource_type)
-          return reporter.report(result) unless schema
-
-          errors = Validator.errors_for(
-            prepare(schema, &transform), result.body
-          )
-          reporter.report_validation(result, errors)
-        end
-
-        def schema_for(resource_type)
           entry = resource_type_entry(resource_type)
-          schema = resource_schema_resolver.schema_for(entry)
-          if schema
-            warn_undeclared_extensions
-          else
-            warn_unresolvable_schema(resource_type)
-          end
-          schema
-        end
+          errors = validation.errors_for(entry, result.body, &transform)
+          return reporter.report(result) unless errors
 
-        def prepare(schema)
-          schema = SparseSchema.relax(schema) if options[:attributes]
-          block_given? ? yield(schema) : schema
+          reporter.report_validation(result, errors)
         end
 
-        def warn_unresolvable_schema(resource_type)
-          reporter.warn(
-            "no schema found for resource type #{resource_type.inspect}; " \
-            'skipping validation'
+        def validation
+          @validation ||= ResourceValidation.new(
+            resource_schema_resolver, reporter, sparse: !options[:attributes].nil?
           )
         end
 
-        def warn_undeclared_extensions
-          resource_schema_resolver.undeclared_extensions.each do |urn|
-            reporter.warn(
-              "schema extension #{urn.inspect} is declared by the resource " \
-              'type but missing from /Schemas'
-            )
-          end
-        end
-
         def reporter
           @reporter ||= Reporter.new(shell)
         end
 
-        def list_query
-          {
-            'filter' => options[:filter],
-            'startIndex' => options[:start_index],
-            'count' => options[:count],
-            'sortBy' => options[:sort_by],
-            'sortOrder' => options[:sort_order],
-            'attributes' => options[:attributes]
-          }
-        end
-
-        def url
-          @url ||= begin
-            resolved = options[:url] || ENV.fetch('SCIM_KIT_URL', nil)
-            raise Thor::Error, '--url is required' if resolved.to_s.empty?
-
-            resolved
-          end
-        end
-
-        def headers
-          @headers ||= options[:header].each_with_object({}) do |header, hash|
-            name, value = header.split(':', 2)
-            if value.nil?
-              raise Thor::Error,
-                "malformed --header #{header.inspect} " \
-                '(expected "Name: Value")'
-            end
-
-            hash[name.to_s.strip] = value.to_s.strip
-          end
+        def settings
+          @settings ||= Settings.new(options)
         end
 
         def client
-          @client ||= Client.new(url, headers: headers)
+          @client ||= Client.new(settings.url, headers: settings.headers)
         end
       end
     end
lib/scim/kit/cli/discovery.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class Discovery
+        RESOURCES = {
+          service_provider_configuration: 'ServiceProviderConfig',
+          schemas: 'Schemas',
+          resource_types: 'ResourceTypes'
+        }.freeze
+
+        def initialize(client)
+          @client = client
+        end
+
+        def fetch
+          documents = {}
+          RESOURCES.each do |key, path|
+            result = client.fetch(path)
+            return result unless result.ok?
+
+            documents[key] = result.body
+          end
+          Http::Result.new(200, documents)
+        end
+
+        def errors_for(documents)
+          documents.each_with_object({}) do |(key, body), errors|
+            document_errors = Validator.errors_for(
+              SchemaRegistry.fetch(key), body
+            )
+            errors[key] = document_errors unless document_errors.empty?
+          end
+        end
+
+        private
+
+        attr_reader :client
+      end
+    end
+  end
+end
lib/scim/kit/cli/resource_validation.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class ResourceValidation
+        def initialize(resolver, reporter, sparse: false)
+          @resolver = resolver
+          @reporter = reporter
+          @sparse = sparse
+        end
+
+        def errors_for(entry, body, &transform)
+          schema = schema_for(entry)
+          return unless schema
+
+          Validator.errors_for(prepare(schema, &transform), body)
+        end
+
+        private
+
+        attr_reader :resolver, :reporter, :sparse
+
+        def schema_for(entry)
+          schema = resolver.schema_for(entry)
+          schema ? warn_undeclared_extensions : warn_unresolvable_schema(entry)
+          schema
+        end
+
+        def prepare(schema)
+          schema = SparseSchema.relax(schema) if sparse
+          block_given? ? yield(schema) : schema
+        end
+
+        def warn_unresolvable_schema(entry)
+          reporter.warn(
+            "no schema found for resource type #{entry[:name].inspect}; " \
+            'skipping validation'
+          )
+        end
+
+        def warn_undeclared_extensions
+          resolver.undeclared_extensions.each do |urn|
+            reporter.warn(
+              "schema extension #{urn.inspect} is declared by the resource " \
+              'type but missing from /Schemas'
+            )
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/settings.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class Settings
+        QUERY = {
+          'filter' => :filter,
+          'startIndex' => :start_index,
+          'count' => :count,
+          'sortBy' => :sort_by,
+          'sortOrder' => :sort_order,
+          'attributes' => :attributes
+        }.freeze
+
+        def initialize(options, env: ENV)
+          @options = options
+          @env = env
+        end
+
+        def url
+          @url ||= begin
+            resolved = options[:url] || env.fetch('SCIM_KIT_URL', nil)
+            raise Thor::Error, '--url is required' if resolved.to_s.empty?
+
+            resolved
+          end
+        end
+
+        def headers
+          @headers ||= Array(options[:header]).to_h { |x| split_header(x) }
+        end
+
+        def list_query
+          QUERY.transform_values { |name| options[name] }
+        end
+
+        private
+
+        attr_reader :options, :env
+
+        def split_header(header)
+          name, value = header.split(':', 2)
+          if value.nil?
+            raise Thor::Error,
+              "malformed --header #{header.inspect} " \
+              '(expected "Name: Value")'
+          end
+
+          [name.to_s.strip, value.to_s.strip]
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli.rb
@@ -5,10 +5,13 @@ require 'thor'
 
 require 'scim/kit/cli/app'
 require 'scim/kit/cli/client'
+require 'scim/kit/cli/discovery'
 require 'scim/kit/cli/reporter'
 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/sparse_schema'
 require 'scim/kit/cli/validator'
spec/scim/kit/cli/discovery_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Discovery do
+  subject { described_class.new(Scim::Kit::Cli::Client.new(base_url)) }
+
+  let(:base_url) { FFaker::Internet.uri('https') }
+  let(:config) { { schemas: ['urn:x'], patch: { supported: true } } }
+  let(:schemas) { { totalResults: 0, Resources: [] } }
+  let(:resource_types) { { totalResults: 0, Resources: [] } }
+
+  before do
+    stub_request(:get, "#{base_url}/ServiceProviderConfig")
+      .to_return(status: 200, body: config.to_json)
+    stub_request(:get, "#{base_url}/Schemas")
+      .to_return(status: 200, body: schemas.to_json)
+    stub_request(:get, "#{base_url}/ResourceTypes")
+      .to_return(status: 200, body: resource_types.to_json)
+  end
+
+  describe '#fetch' do
+    it 'keys each document by its resource name' do
+      expect(subject.fetch.body.keys)
+        .to eql(%i[service_provider_configuration schemas resource_types])
+    end
+
+    it 'returns an ok result when every request succeeds' do
+      expect(subject.fetch).to be_ok
+    end
+
+    it 'collects each document body' do
+      expect(subject.fetch.body[:service_provider_configuration])
+        .to eql(config)
+    end
+
+    context 'when a request fails' do
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig")
+          .to_return(status: 500, body: { detail: 'boom' }.to_json)
+      end
+
+      it 'returns the failed result' do
+        expect(subject.fetch).not_to be_ok
+      end
+
+      it 'stops before requesting the later documents' do
+        subject.fetch
+
+        expect(a_request(:get, "#{base_url}/Schemas")).not_to have_been_made
+      end
+    end
+  end
+
+  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
+
+      expect(subject.errors_for(documents)).to eql({})
+    end
+
+    it 'keys errors by the document that failed' do
+      errors = subject.errors_for(service_provider_configuration: {})
+
+      expect(errors.keys).to eql([:service_provider_configuration])
+    end
+
+    it 'omits documents that conform' do
+      documents = { schemas: valid_list, resource_types: {} }
+
+      expect(subject.errors_for(documents).keys).to eql([:resource_types])
+    end
+  end
+
+  def valid_config
+    {
+      schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
+      patch: { supported: true },
+      bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+      filter: { supported: false, maxResults: 0 },
+      changePassword: { supported: false }, sort: { supported: false },
+      etag: { supported: false }, authenticationSchemes: []
+    }
+  end
+
+  def valid_list
+    {
+      schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+      totalResults: 0, Resources: []
+    }
+  end
+end
spec/scim/kit/cli/settings_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Settings do
+  def settings(options, env: {})
+    described_class.new(options, env: env)
+  end
+
+  describe '#url' do
+    it 'prefers the --url option' do
+      expect(settings({ url: 'https://a' }).url).to eql('https://a')
+    end
+
+    it 'falls back to SCIM_KIT_URL' do
+      result = settings({}, env: { 'SCIM_KIT_URL' => 'https://b' })
+
+      expect(result.url).to eql('https://b')
+    end
+
+    it 'raises when neither is given' do
+      expect { settings({}).url }
+        .to raise_error(Thor::Error, /--url is required/)
+    end
+
+    it 'raises when the url is blank' do
+      expect { settings({ url: '' }).url }
+        .to raise_error(Thor::Error, /--url is required/)
+    end
+  end
+
+  describe '#headers' do
+    it 'parses a Name: Value pair' do
+      result = settings({ header: ['Authorization: Bearer xyz'] })
+
+      expect(result.headers).to eql('Authorization' => 'Bearer xyz')
+    end
+
+    it 'keeps colons in the value' do
+      result = settings({ header: ['X-A: a:b'] })
+
+      expect(result.headers).to eql('X-A' => 'a:b')
+    end
+
+    it 'parses repeated headers' do
+      result = settings({ header: ['A: 1', 'B: 2'] })
+
+      expect(result.headers).to eql('A' => '1', 'B' => '2')
+    end
+
+    it 'defaults to no headers' do
+      expect(settings({}).headers).to eql({})
+    end
+
+    it 'raises on a header without a colon' do
+      expect { settings({ header: ['nope'] }).headers }
+        .to raise_error(Thor::Error, /malformed --header/)
+    end
+  end
+
+  describe '#list_query' do
+    let(:options) do
+      { filter: 'a eq 1', start_index: 2, count: 3,
+        sort_by: 'userName', sort_order: 'ascending', attributes: 'id' }
+    end
+
+    it 'maps options onto their SCIM parameter names' do
+      expect(settings(options).list_query).to eql(
+        'filter' => 'a eq 1', 'startIndex' => 2, 'count' => 3,
+        'sortBy' => 'userName', 'sortOrder' => 'ascending', 'attributes' => 'id'
+      )
+    end
+
+    it 'leaves absent options nil for the client to drop' do
+      expect(settings({}).list_query.values).to all(be_nil)
+    end
+  end
+end
.rubocop.yml
@@ -58,10 +58,6 @@ Metrics/BlockLength:
     - 'Rakefile'
     - 'spec/**/*.rb'
 
-Metrics/ClassLength:
-  Exclude:
-    - 'lib/scim/kit/cli/app.rb'
-
 Metrics/ModuleLength:
   Exclude:
     - 'spec/**/*.rb'