Commit 52cf40b
Changed files (17)
bin
exe
lib
scim
spec
bin/run
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+bundle exec ./exe/scim-kit "$@"
exe/scim-kit
@@ -2,3 +2,8 @@
# frozen_string_literal: true
require 'scim/kit'
+require 'scim/kit/cli'
+
+Scim::Kit.logger = Logger.new(File::NULL)
+
+Scim::Kit::Cli::App.start(ARGV)
lib/scim/kit/cli/app.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+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
+
+ class_option :url, desc: 'Base URL of the SCIM server (or SCIM_KIT_URL)'
+ class_option :header, type: :array, default: [],
+ desc: 'Extra header as "Name: Value" (repeatable)'
+
+ desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
+ def discover
+ Reporting.rescue_errors(shell) { fetch_discovery }
+ end
+
+ desc 'list RESOURCE_TYPE', 'List resources of a given type'
+ method_option :filter, desc: 'SCIM filter expression'
+ method_option :start_index, type: :numeric, desc: 'Pagination start index'
+ method_option :count, type: :numeric, desc: 'Page size'
+ method_option :sort_by, desc: 'Attribute to sort by'
+ method_option :sort_order, desc: 'ascending or descending'
+ method_option :attributes, desc: 'Comma-separated attribute names to return'
+ def list(resource_type)
+ Reporting.rescue_errors(shell) { fetch_list(resource_type) }
+ end
+
+ desc 'get RESOURCE_TYPE ID', 'Fetch a single resource'
+ method_option :attributes, desc: 'Comma-separated attribute names to return'
+ def get(resource_type, id)
+ Reporting.rescue_errors(shell) { fetch_resource(resource_type, id) }
+ end
+
+ private
+
+ def fetch_discovery
+ responses = {}
+ RESOURCES.each do |key, path|
+ result = http.fetch(Cli.join_uri(url, path), headers: headers)
+ return Reporting.report(result, shell) unless result.ok?
+
+ responses[key] = result.body
+ end
+ Reporting.report(Http::Result.new(200, responses), shell)
+ end
+
+ def fetch_list(resource_type)
+ uri = Cli.join_uri(url, resolve_endpoint(resource_type))
+ query = list_query_string
+ uri.query = query unless query.empty?
+ report_fetch(uri)
+ end
+
+ def fetch_resource(resource_type, id)
+ uri = Cli.join_uri(url, "#{resolve_endpoint(resource_type)}/#{id}")
+ if options[:attributes]
+ uri.query = URI.encode_www_form(attributes: options[:attributes])
+ end
+ report_fetch(uri)
+ end
+
+ def report_fetch(uri)
+ Reporting.report(http.fetch(uri, headers: headers), shell)
+ end
+
+ def resolve_endpoint(resource_type)
+ ResourceTypeResolver.new(http, url, headers: headers)
+ .endpoint_for(resource_type)
+ .delete_prefix('/')
+ end
+
+ def list_query_string
+ URI.encode_www_form(
+ {
+ 'filter' => options[:filter],
+ 'startIndex' => options[:start_index],
+ 'count' => options[:count],
+ 'sortBy' => options[:sort_by],
+ 'sortOrder' => options[:sort_order],
+ 'attributes' => options[:attributes]
+ }.compact
+ )
+ 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
+ end
+
+ def http
+ @http ||= Scim::Kit::Http.new
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/reporting.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ module Reporting
+ def self.report(result, shell)
+ if result.ok?
+ shell.say(JSON.pretty_generate(result.body))
+ exit(0)
+ else
+ shell.say_error(JSON.pretty_generate(result.body))
+ exit(1)
+ end
+ end
+
+ def self.report_error(message, shell)
+ shell.say_error(JSON.pretty_generate(detail: message))
+ exit(1)
+ end
+
+ def self.rescue_errors(shell)
+ yield
+ rescue RequestFailed => error
+ report(error.result, shell)
+ rescue UnknownResourceType, MissingEndpoint, InvalidResponse => error
+ report_error(error.message, shell)
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/resource_type_resolver.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ class ResourceTypeResolver
+ def initialize(http, base_url, headers: {})
+ @http = http
+ @base_url = base_url
+ @headers = headers
+ end
+
+ def endpoint_for(name)
+ types = resource_types
+ match = types.find { |x| matches?(x, name) }
+ unless match
+ raise UnknownResourceType.new(name, types.map { |x| x[:name] })
+ end
+
+ endpoint = match[:endpoint]
+ raise MissingEndpoint, name if endpoint.to_s.empty?
+
+ endpoint
+ end
+
+ private
+
+ attr_reader :http, :base_url, :headers
+
+ def matches?(type, name)
+ type[:id]&.casecmp?(name) || type[:name]&.casecmp?(name)
+ end
+
+ def resource_types
+ uri = Cli.join_uri(base_url, 'ResourceTypes')
+ result = http.fetch(uri, headers: headers)
+ raise RequestFailed, result unless result.ok?
+ unless result.body.is_a?(Array)
+ raise InvalidResponse, 'expected /ResourceTypes to return a list'
+ end
+
+ result.body
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'thor'
+
+require 'scim/kit/cli/reporting'
+require 'scim/kit/cli/resource_type_resolver'
+require 'scim/kit/cli/app'
+
+module Scim
+ module Kit
+ module Cli
+ class Error < Scim::Kit::Error; end
+
+ class RequestFailed < Error
+ attr_reader :result
+
+ def initialize(result)
+ @result = result
+ super("request failed with status #{result.status.inspect}")
+ end
+ end
+
+ class UnknownResourceType < Error
+ def initialize(name, known_names)
+ super("unknown resource type #{name.inspect} (known: #{known_names.join(', ')})")
+ end
+ end
+
+ class MissingEndpoint < Error
+ def initialize(name)
+ super("resource type #{name.inspect} has no endpoint")
+ end
+ end
+
+ class InvalidResponse < Error; end
+
+ def self.join_uri(base_url, path)
+ URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
+ end
+ end
+ end
+end
lib/scim/kit/http.rb
@@ -3,6 +3,12 @@
module Scim
module Kit
class Http
+ Result = Struct.new(:status, :body) do
+ def ok?
+ !status.nil? && (200..299).cover?(status)
+ end
+ end
+
attr_reader :driver, :retries
def initialize(driver: Http.default_driver, retries: 3)
@@ -11,13 +17,18 @@ module Scim
end
def get(uri)
+ result = fetch(uri)
+ result.ok? ? result.body : {}
+ end
+
+ def fetch(uri, headers: {})
driver.with_retry(retries: retries) do |client|
- response = client.get(uri)
- ok?(response) ? JSON.parse(response.body, symbolize_names: true) : {}
+ response = client.get(uri, headers: headers)
+ Result.new(response.code.to_i, parse(response.body))
end
rescue *Net::Hippie::CONNECTION_ERRORS => error
Scim::Kit.logger.error(error)
- {}
+ Result.new(nil, { detail: error.message })
end
def self.default_driver
@@ -40,8 +51,12 @@ module Scim
private
- def ok?(response)
- response.is_a?(Net::HTTPSuccess)
+ def parse(body)
+ return {} if body.nil?
+
+ JSON.parse(body, symbolize_names: true)
+ rescue JSON::ParserError
+ { detail: body }
end
end
end
spec/scim/kit/cli/app_spec.rb
@@ -0,0 +1,263 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::App do
+ let(:base_url) { FFaker::Internet.uri('https') }
+ let(:resource_types) { [{ id: 'User', name: 'User', endpoint: '/Users' }] }
+
+ def app(options = {})
+ described_class.new([], { 'url' => base_url }.merge(options))
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes")
+ .to_return(status: 200, body: resource_types.to_json)
+ end
+
+ shared_examples 'a resource-type-resolving command' do |call|
+ context 'when the resource type is unknown' do
+ it 'reports the unknown resource type' do
+ expect { exit_status { call.call(app, 'Nope') } }.to output(/Nope/).to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { call.call(app, 'Nope') }).to eq(1)
+ end
+ end
+
+ context 'when fetching ResourceTypes fails' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes")
+ .to_return(status: 500, body: { detail: 'boom' }.to_json)
+ end
+
+ it 'reports the failure' do
+ expect { exit_status { call.call(app, 'User') } }
+ .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+ end
+ end
+ end
+
+ describe '#discover' do
+ let(:service_provider_configuration) { { patch: { supported: true } } }
+ let(:schemas) { [{ id: 'User', name: 'User' }] }
+
+ context 'when every request succeeds' do
+ before do
+ stub_request(:get, "#{base_url}/ServiceProviderConfig")
+ .to_return(status: 200, body: service_provider_configuration.to_json)
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: schemas.to_json)
+ end
+
+ let(:expected_output) do
+ JSON.pretty_generate(
+ service_provider_configuration: service_provider_configuration,
+ schemas: schemas,
+ resource_types: resource_types
+ )
+ end
+
+ it 'prints the combined configuration as pretty json' do
+ expect { exit_status { app.discover } }
+ .to output("#{expected_output}\n").to_stdout
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+
+ expect(exit_status { app.discover }).to eq(0)
+ end
+ end
+
+ context 'when the ServiceProviderConfig request fails' do
+ before do
+ stub_request(:get, "#{base_url}/ServiceProviderConfig")
+ .to_return(status: 500, body: { detail: 'boom' }.to_json)
+ end
+
+ it 'reports the failure without requesting Schemas' do
+ allow($stderr).to receive(:print)
+ exit_status { app.discover }
+
+ expect(a_request(:get, "#{base_url}/Schemas")).not_to have_been_made
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { app.discover }).to eq(1)
+ end
+ end
+ end
+
+ describe '#list' do
+ include_examples 'a resource-type-resolving command', ->(a, type) { a.list(type) }
+
+ context 'when the resource type and the list request both succeed' do
+ let(:list_response) { { totalResults: 1, Resources: [{ id: '1' }] } }
+ let(:instance) do
+ app(
+ 'filter' => 'userName eq "bjensen"',
+ 'start_index' => 2,
+ 'count' => 10,
+ 'sort_by' => 'userName',
+ 'sort_order' => 'ascending',
+ 'attributes' => 'userName,emails'
+ )
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Users")
+ .with(
+ query: {
+ 'filter' => 'userName eq "bjensen"',
+ 'startIndex' => '2',
+ 'count' => '10',
+ 'sortBy' => 'userName',
+ 'sortOrder' => 'ascending',
+ 'attributes' => 'userName,emails'
+ }
+ )
+ .to_return(status: 200, body: list_response.to_json)
+ end
+
+ it 'prints the list response as pretty json' do
+ expect { exit_status { instance.list('User') } }
+ .to output("#{JSON.pretty_generate(list_response)}\n").to_stdout
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+
+ expect(exit_status { instance.list('User') }).to eq(0)
+ end
+ end
+
+ context 'when the list request fails' do
+ before do
+ stub_request(:get, "#{base_url}/Users")
+ .to_return(status: 404, body: { detail: 'not found' }.to_json)
+ end
+
+ it 'reports the failure' do
+ expect { exit_status { app.list('User') } }
+ .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { app.list('User') }).to eq(1)
+ end
+ end
+ end
+
+ describe '#get' do
+ include_examples 'a resource-type-resolving command', ->(a, type) { a.get(type, '123') }
+
+ context 'when the resource type and the get request both succeed' do
+ let(:resource) { { id: '123', userName: 'bjensen' } }
+ let(:instance) { app('attributes' => 'userName,emails') }
+
+ before do
+ stub_request(:get, "#{base_url}/Users/123")
+ .with(query: { 'attributes' => 'userName,emails' })
+ .to_return(status: 200, body: resource.to_json)
+ end
+
+ it 'prints the resource as pretty json' do
+ expect { exit_status { instance.get('User', '123') } }
+ .to output("#{JSON.pretty_generate(resource)}\n").to_stdout
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+
+ expect(exit_status { instance.get('User', '123') }).to eq(0)
+ end
+ end
+
+ context 'when the get request fails' do
+ before do
+ stub_request(:get, "#{base_url}/Users/123")
+ .to_return(status: 404, body: { detail: 'not found' }.to_json)
+ end
+
+ it 'reports the failure' do
+ expect { exit_status { app.get('User', '123') } }
+ .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { app.get('User', '123') }).to eq(1)
+ end
+ end
+ end
+
+ describe 'header parsing' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes")
+ .with(headers: { 'Authorization' => 'Bearer xyz', 'X-Test' => 'value' })
+ .to_return(status: 200, body: resource_types.to_json)
+ stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
+ end
+
+ it 'sends repeated --header flags as request headers' do
+ allow($stdout).to receive(:print)
+ instance = app('header' => ['Authorization: Bearer xyz', 'X-Test: value'])
+
+ exit_status { instance.list('User') }
+
+ expect(a_request(:get, "#{base_url}/Users")).to have_been_made
+ end
+ end
+
+ describe 'CLI argv parsing via .start' do
+ around do |example|
+ original = ENV.fetch('SCIM_KIT_URL', nil)
+ example.run
+ ENV['SCIM_KIT_URL'] = original
+ end
+
+ before do
+ ENV.delete('SCIM_KIT_URL')
+ stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
+ end
+
+ it 'exits 1 with a usage message when RESOURCE_TYPE is missing' do
+ allow($stderr).to receive(:print)
+
+ expect { exit_status { described_class.start(['list', '--url', base_url]) } }
+ .to output(/no arguments/).to_stderr
+ end
+
+ it 'exits 1 when --url is missing entirely' do
+ allow($stderr).to receive(:puts)
+
+ status = exit_status { described_class.start(%w[list User]) }
+
+ expect(status).to eq(1)
+ end
+
+ it 'reads --url from SCIM_KIT_URL when --url is omitted' do
+ ENV['SCIM_KIT_URL'] = base_url
+ allow($stdout).to receive(:print)
+
+ status = exit_status { described_class.start(%w[list User]) }
+
+ expect(status).to eq(0)
+ end
+
+ it 'exits 1 with a usage message for a malformed --header' do
+ allow($stderr).to receive(:print)
+ argv = ['list', 'User', '--url', base_url, '--header', 'BearerXYZ']
+
+ expect { exit_status { described_class.start(argv) } }
+ .to output(/malformed --header/).to_stderr
+ end
+ end
+end
spec/scim/kit/cli/reporting_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Reporting do
+ let(:shell) { Thor::Shell::Basic.new }
+
+ describe '.report' do
+ context 'when the result is ok' do
+ let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
+
+ it 'prints the body as pretty json to stdout' do
+ expect { exit_status { described_class.report(result, shell) } }
+ .to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+
+ expect(exit_status { described_class.report(result, shell) }).to eq(0)
+ end
+ end
+
+ context 'when the result is not ok' do
+ let(:result) { Scim::Kit::Http::Result.new(404, { detail: 'not found' }) }
+
+ it 'prints the body as pretty json to stderr' do
+ expect { exit_status { described_class.report(result, shell) } }
+ .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { described_class.report(result, shell) }).to eq(1)
+ end
+ end
+ end
+
+ describe '.report_error' do
+ it 'prints a synthesized detail message as pretty json to stderr' do
+ expect { exit_status { described_class.report_error('boom', shell) } }
+ .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stderr).to receive(:print)
+
+ expect(exit_status { described_class.report_error('boom', shell) }).to eq(1)
+ end
+ end
+
+ describe '.rescue_errors' do
+ it 'returns the value of the block when nothing is raised' do
+ expect(described_class.rescue_errors(shell) { 'ok' }).to eq('ok')
+ end
+
+ context 'when the block raises Cli::RequestFailed' do
+ let(:result) { Scim::Kit::Http::Result.new(500, { detail: 'boom' }) }
+ let(:block) do
+ -> { described_class.rescue_errors(shell) { raise Scim::Kit::Cli::RequestFailed, result } }
+ end
+
+ it 'reports the failed result' do
+ expect { exit_status(&block) }
+ .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+ end
+ end
+
+ context 'when the block raises Cli::UnknownResourceType' do
+ let(:block) do
+ lambda do
+ described_class.rescue_errors(shell) do
+ raise Scim::Kit::Cli::UnknownResourceType.new('Nope', ['User'])
+ end
+ end
+ end
+
+ it 'reports the error message' do
+ expect { exit_status(&block) }.to output(/Nope/).to_stderr
+ end
+ end
+
+ context 'when the block raises Cli::MissingEndpoint' do
+ let(:block) do
+ lambda do
+ described_class.rescue_errors(shell) { raise Scim::Kit::Cli::MissingEndpoint, 'User' }
+ end
+ end
+
+ it 'reports the error message' do
+ expect { exit_status(&block) }.to output(/User/).to_stderr
+ end
+ end
+
+ context 'when the block raises Cli::InvalidResponse' do
+ let(:block) do
+ lambda do
+ described_class.rescue_errors(shell) { raise Scim::Kit::Cli::InvalidResponse, 'bad shape' }
+ end
+ end
+
+ it 'reports the error message' do
+ expect { exit_status(&block) }.to output(/bad shape/).to_stderr
+ end
+ end
+ end
+end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
+ subject { described_class.new(Scim::Kit::Http.new, base_url, headers: headers) }
+
+ let(:base_url) { FFaker::Internet.uri('https') }
+ let(:headers) { {} }
+
+ describe '#endpoint_for' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+ status: 200,
+ body: [
+ { id: 'User', name: 'User', endpoint: '/Users' },
+ { id: 'Group', name: 'Group', endpoint: '/Groups' }
+ ].to_json
+ )
+ end
+
+ specify { expect(subject.endpoint_for('User')).to eql('/Users') }
+ specify { expect(subject.endpoint_for('user')).to eql('/Users') }
+ specify { expect(subject.endpoint_for('Group')).to eql('/Groups') }
+
+ it 'raises when no resource type matches the given name' do
+ expect { subject.endpoint_for('Nope') }.to raise_error(
+ Scim::Kit::Cli::UnknownResourceType, /Nope/
+ )
+ end
+
+ context 'with custom headers' do
+ let(:headers) { { 'Authorization' => 'Bearer xyz' } }
+
+ it 'forwards them to the ResourceTypes request' do
+ subject.endpoint_for('User')
+
+ expect(a_request(:get, "#{base_url}/ResourceTypes").with(headers: headers)).to have_been_made
+ end
+ end
+ end
+
+ context 'when the matched resource type has no endpoint' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+ status: 200,
+ body: [{ id: 'User', name: 'User' }].to_json
+ )
+ end
+
+ it 'raises MissingEndpoint' do
+ expect { subject.endpoint_for('User') }.to raise_error(
+ Scim::Kit::Cli::MissingEndpoint, /User/
+ )
+ end
+ end
+
+ context 'when the request fails' do
+ before { stub_request(:get, "#{base_url}/ResourceTypes").to_return(status: 500, body: '{}') }
+
+ it 'raises' do
+ expect { subject.endpoint_for('User') }.to raise_error(Scim::Kit::Cli::RequestFailed)
+ end
+ end
+
+ context 'when the response body is not a list' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+ status: 200,
+ body: { Resources: [] }.to_json
+ )
+ end
+
+ it 'raises InvalidResponse' do
+ expect { subject.endpoint_for('User') }.to raise_error(Scim::Kit::Cli::InvalidResponse)
+ end
+ end
+end
spec/scim/kit/cli_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli do
+ describe '.join_uri' do
+ it 'joins a base url without a trailing slash to a path' do
+ uri = described_class.join_uri('https://example.com/scim/v2', 'Users')
+
+ expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+ end
+
+ it 'joins a base url with a trailing slash to a path' do
+ uri = described_class.join_uri('https://example.com/scim/v2/', 'Users')
+
+ expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+ end
+
+ it 'joins a base url with multiple trailing slashes to a path' do
+ uri = described_class.join_uri('https://example.com/scim/v2///', 'Users')
+
+ expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+ end
+ end
+end
spec/scim/kit/http_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Http do
+ subject { described_class.new }
+
+ let(:uri) { URI(FFaker::Internet.uri('https')) }
+
+ describe '#fetch' do
+ context 'when the response is successful' do
+ let(:body) { { id: '123' } }
+
+ before { stub_request(:get, uri).to_return(status: 200, body: body.to_json) }
+
+ specify { expect(subject.fetch(uri)).to be_ok }
+ specify { expect(subject.fetch(uri).status).to be(200) }
+ specify { expect(subject.fetch(uri).body).to eql(body) }
+ end
+
+ context 'when the response is a scim error' do
+ let(:error_body) { { detail: 'Resource not found', status: '404' } }
+
+ before { stub_request(:get, uri).to_return(status: 404, body: error_body.to_json) }
+
+ specify { expect(subject.fetch(uri)).not_to be_ok }
+ specify { expect(subject.fetch(uri).status).to be(404) }
+ specify { expect(subject.fetch(uri).body).to eql(error_body) }
+ end
+
+ context 'when the response body is not json' do
+ before { stub_request(:get, uri).to_return(status: 500, body: 'boom') }
+
+ specify { expect(subject.fetch(uri)).not_to be_ok }
+ specify { expect(subject.fetch(uri).body).to eql(detail: 'boom') }
+ end
+
+ context 'when the response has no body' do
+ before { stub_request(:get, uri).to_return(status: 204, body: nil) }
+
+ specify { expect(subject.fetch(uri)).to be_ok }
+ specify { expect(subject.fetch(uri).body).to eql({}) }
+ end
+
+ context 'when the connection fails' do
+ subject { described_class.new(retries: 0) }
+
+ before { stub_request(:get, uri).to_raise(Errno::ECONNREFUSED) }
+
+ specify { expect(subject.fetch(uri)).not_to be_ok }
+ specify { expect(subject.fetch(uri).status).to be_nil }
+ specify { expect(subject.fetch(uri).body[:detail]).to include('Connection refused') }
+ end
+
+ context 'when headers are provided' do
+ before do
+ stub_request(:get, uri)
+ .with(headers: { 'X-Test' => 'value' })
+ .to_return(status: 200, body: '{}')
+ end
+
+ specify { expect(subject.fetch(uri, headers: { 'X-Test' => 'value' })).to be_ok }
+ end
+ end
+end
spec/spec_helper.rb
@@ -2,6 +2,7 @@
require 'bundler/setup'
require 'scim/kit'
+require 'scim/kit/cli'
require 'ffaker'
require 'json'
require 'parslet/convenience'
@@ -10,6 +11,15 @@ require 'webmock/rspec'
Scim::Kit.logger = Logger.new('/dev/null')
+module ExitStatusHelper
+ def exit_status
+ yield
+ nil
+ rescue SystemExit => error
+ error.status
+ end
+end
+
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = '.rspec_status'
@@ -20,4 +30,6 @@ RSpec.configure do |config|
config.expect_with :rspec do |c|
c.syntax = :expect
end
+
+ config.include ExitStatusHelper
end
.rubocop.yml
@@ -88,6 +88,10 @@ Style/IfUnlessModifier:
Exclude:
- 'lib/scim/kit/v2/attribute.rb'
+Style/StderrPuts:
+ Exclude:
+ - 'lib/scim/kit/cli/reporting.rb'
+
Style/StringLiterals:
EnforcedStyle: 'single_quotes'
CHANGELOG.md
@@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+- Add a `scim-kit` CLI with `discover`, `list`, and `get` commands for
+ reading a remote SCIM server's configuration and resources.
## [0.8.0] - 2026-03-31
### Changed
Gemfile.lock
@@ -5,6 +5,7 @@ PATH
activemodel (>= 6.1)
net-hippie (~> 1.0)
parslet (~> 2.0)
+ thor (~> 1.0)
tilt (~> 2.0)
tilt-jbuilder (~> 0.7)
scim-kit.gemspec
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'activemodel', '>= 6.1'
spec.add_dependency 'net-hippie', '~> 1.0'
spec.add_dependency 'parslet', '~> 2.0'
+ spec.add_dependency 'thor', '~> 1.0'
spec.add_dependency 'tilt', '~> 2.0'
spec.add_dependency 'tilt-jbuilder', '~> 0.7'
spec.add_development_dependency 'bundler-audit', '~> 0.6'