Commit 2c46fb2
Changed files (23)
lib
spec
lib/scim/kit/cli/schemas/list_response.schema.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "schemas": { "type": "array", "items": { "type": "string" } },
+ "totalResults": { "type": "integer" },
+ "itemsPerPage": { "type": "integer" },
+ "startIndex": { "type": "integer" },
+ "Resources": { "type": "array", "items": {} }
+ },
+ "required": ["schemas", "totalResults"],
+ "additionalProperties": false
+}
lib/scim/kit/cli/schemas/resource_types.schema.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "schemas": { "type": "array", "items": { "type": "string" } },
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": "string" },
+ "endpoint": { "type": "string" },
+ "schema": { "type": "string" },
+ "schemaExtensions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "schema": { "type": "string" },
+ "required": { "type": "boolean" }
+ },
+ "required": ["schema", "required"],
+ "additionalProperties": false
+ }
+ },
+ "externalId": { "type": "string" },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "resourceType": { "type": "string" },
+ "created": { "type": "string", "format": "date-time" },
+ "lastModified": { "type": "string", "format": "date-time" },
+ "location": { "type": "string" },
+ "version": { "type": "string" }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ },
+ "required": ["schemas", "name", "endpoint", "schema"],
+ "additionalProperties": false
+ }
+}
lib/scim/kit/cli/schemas/schemas.schema.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "schemas": { "type": "array", "items": { "type": "string" } },
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": "string" },
+ "attributes": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/attribute" }
+ },
+ "externalId": { "type": "string" },
+ "meta": { "$ref": "#/$defs/meta" }
+ },
+ "required": ["schemas", "id", "attributes"],
+ "additionalProperties": false
+ },
+ "$defs": {
+ "attribute": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "type": {
+ "type": "string",
+ "enum": [
+ "string", "boolean", "decimal", "integer", "dateTime",
+ "reference", "complex"
+ ]
+ },
+ "multiValued": { "type": "boolean" },
+ "description": { "type": "string" },
+ "required": { "type": "boolean" },
+ "canonicalValues": { "type": "array" },
+ "caseExact": { "type": "boolean" },
+ "mutability": {
+ "type": "string",
+ "enum": ["readOnly", "readWrite", "immutable", "writeOnly"]
+ },
+ "returned": {
+ "type": "string",
+ "enum": ["always", "never", "default", "request"]
+ },
+ "uniqueness": {
+ "type": "string",
+ "enum": ["none", "server", "global"]
+ },
+ "referenceTypes": { "type": "array", "items": { "type": "string" } },
+ "subAttributes": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/attribute" }
+ }
+ },
+ "required": ["name", "type"],
+ "additionalProperties": false
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "resourceType": { "type": "string" },
+ "created": { "type": "string", "format": "date-time" },
+ "lastModified": { "type": "string", "format": "date-time" },
+ "location": { "type": "string" },
+ "version": { "type": "string" }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ }
+}
lib/scim/kit/cli/schemas/service_provider_config.schema.json
@@ -0,0 +1,85 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "schemas": { "type": "array", "items": { "type": "string" } },
+ "id": { "type": "string" },
+ "externalId": { "type": "string" },
+ "documentationUri": { "type": "string" },
+ "patch": {
+ "type": "object",
+ "properties": { "supported": { "type": "boolean" } },
+ "required": ["supported"],
+ "additionalProperties": false
+ },
+ "bulk": {
+ "type": "object",
+ "properties": {
+ "supported": { "type": "boolean" },
+ "maxOperations": { "type": "integer" },
+ "maxPayloadSize": { "type": "integer" }
+ },
+ "required": ["supported", "maxOperations", "maxPayloadSize"],
+ "additionalProperties": false
+ },
+ "filter": {
+ "type": "object",
+ "properties": {
+ "supported": { "type": "boolean" },
+ "maxResults": { "type": "integer" }
+ },
+ "required": ["supported", "maxResults"],
+ "additionalProperties": false
+ },
+ "changePassword": {
+ "type": "object",
+ "properties": { "supported": { "type": "boolean" } },
+ "required": ["supported"],
+ "additionalProperties": false
+ },
+ "sort": {
+ "type": "object",
+ "properties": { "supported": { "type": "boolean" } },
+ "required": ["supported"],
+ "additionalProperties": false
+ },
+ "etag": {
+ "type": "object",
+ "properties": { "supported": { "type": "boolean" } },
+ "required": ["supported"],
+ "additionalProperties": false
+ },
+ "authenticationSchemes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": "string" },
+ "specUri": { "type": "string" },
+ "documentationUri": { "type": "string" }
+ },
+ "required": ["type", "name", "description"],
+ "additionalProperties": false
+ }
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "resourceType": { "type": "string" },
+ "created": { "type": "string", "format": "date-time" },
+ "lastModified": { "type": "string", "format": "date-time" },
+ "location": { "type": "string" },
+ "version": { "type": "string" }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "schemas", "patch", "bulk", "filter", "changePassword", "sort", "etag",
+ "authenticationSchemes"
+ ],
+ "additionalProperties": false
+}
lib/scim/kit/cli/app.rb
@@ -17,6 +17,8 @@ module Scim
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)'
+ class_option :validate, type: :boolean, default: false,
+ desc: 'Validate the response against a JSON Schema'
desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
def discover
@@ -43,21 +45,51 @@ module Scim
private
def fetch_discovery
+ responses, error = fetch_discovery_responses
+ return Reporting.report(error, shell) if error
+
+ combined = Http::Result.new(200, responses)
+ report_discovery(combined, responses)
+ end
+
+ def fetch_discovery_responses
responses = {}
RESOURCES.each do |key, path|
- result = http.fetch(Cli.join_uri(url, path), headers: headers)
- return Reporting.report(result, shell) unless result.ok?
+ uri = Cli.join_uri(url, path)
+ result = http.fetch(uri, headers: headers)
+ return [responses, result] unless result.ok?
responses[key] = result.body
end
- Reporting.report(Http::Result.new(200, responses), shell)
+ [responses, nil]
+ end
+
+ def report_discovery(combined, responses)
+ if options[:validate]
+ Reporting.report_with_validation(
+ combined, shell, discovery_errors(responses)
+ )
+ else
+ Reporting.report(combined, shell)
+ end
+ end
+
+ 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
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)
+ result = http.fetch(uri, headers: headers)
+ validate_and_report(result, resource_type) do |schema|
+ SchemaRegistry.list_response_with_items(schema)
+ end
end
def fetch_resource(resource_type, id)
@@ -65,17 +97,48 @@ module Scim
if options[:attributes]
uri.query = URI.encode_www_form(attributes: options[:attributes])
end
- report_fetch(uri)
+ result = http.fetch(uri, headers: headers)
+ validate_and_report(result, resource_type) { |schema| schema }
end
- def report_fetch(uri)
- Reporting.report(http.fetch(uri, headers: headers), shell)
+ def resolve_endpoint(resource_type)
+ endpoint = resource_type_entry(resource_type)[:endpoint]
+ raise MissingEndpoint, resource_type if endpoint.to_s.empty?
+
+ endpoint.delete_prefix('/')
end
- def resolve_endpoint(resource_type)
- ResourceTypeResolver.new(http, url, headers: headers)
- .endpoint_for(resource_type)
- .delete_prefix('/')
+ def resource_type_entry(resource_type)
+ @resource_type_entry ||=
+ resource_type_resolver.resource_type_for(resource_type)
+ end
+
+ def resource_type_resolver
+ @resource_type_resolver ||= ResourceTypeResolver.new(
+ http, url, headers: headers
+ )
+ end
+
+ def resource_schema_resolver
+ @resource_schema_resolver ||= ResourceSchemaResolver.new(
+ http, url, headers: headers
+ )
+ end
+
+ def validate_and_report(result, resource_type)
+ unless result.ok? && options[:validate]
+ return Reporting.report(result, shell)
+ end
+
+ entry = resource_type_entry(resource_type)
+ schema = resource_schema_resolver.schema_for(entry)
+ unless schema
+ Reporting.warn_unresolvable_schema(resource_type, shell)
+ return Reporting.report(result, shell)
+ end
+
+ errors = Validator.errors_for(yield(schema), result.body)
+ Reporting.report_with_validation(result, shell, errors)
end
def list_query_string
lib/scim/kit/cli/reporting.rb
@@ -19,6 +19,25 @@ module Scim
exit(1)
end
+ def self.report_with_validation(result, shell, errors)
+ shell.say(JSON.pretty_generate(result.body))
+ if errors.empty?
+ exit(0)
+ else
+ shell.say_error(
+ JSON.pretty_generate(validation_errors: errors)
+ )
+ exit(1)
+ end
+ end
+
+ def self.warn_unresolvable_schema(resource_type, shell)
+ shell.say_error(
+ 'warning: no schema found for resource type ' \
+ "#{resource_type.inspect}; skipping validation"
+ )
+ end
+
def self.rescue_errors(shell)
yield
rescue RequestFailed => error
lib/scim/kit/cli/resource_schema_resolver.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ class ResourceSchemaResolver
+ COMMON_PROPERTIES = {
+ 'schemas' => {
+ 'type' => 'array', 'items' => { 'type' => 'string' }
+ },
+ 'id' => { 'type' => 'string' },
+ 'externalId' => { 'type' => 'string' },
+ 'meta' => {
+ 'type' => 'object',
+ 'properties' => {
+ 'resourceType' => { 'type' => 'string' },
+ 'created' => { 'type' => 'string', 'format' => 'date-time' },
+ 'lastModified' => {
+ 'type' => 'string', 'format' => 'date-time'
+ },
+ 'location' => { 'type' => 'string' },
+ 'version' => { 'type' => 'string' }
+ },
+ 'required' => [],
+ 'additionalProperties' => false
+ }
+ }.freeze
+
+ def initialize(http, base_url, headers: {})
+ @http = http
+ @base_url = base_url
+ @headers = headers
+ end
+
+ def schema_for(resource_type)
+ schemas = fetch_schemas(urns_for(resource_type))
+ core = schemas[resource_type[:schema]]
+ return nil if core.nil?
+
+ compose(resource_type, schemas, core)
+ end
+
+ private
+
+ attr_reader :http, :base_url, :headers
+
+ def urns_for(resource_type)
+ extensions = Array(resource_type[:schemaExtensions])
+ [
+ resource_type[:schema],
+ *extensions.map { |e| e[:schema] }
+ ].compact
+ end
+
+ def compose(resource_type, schemas, core)
+ converted = ScimSchemaConverter.convert(core)
+ properties = COMMON_PROPERTIES.merge(converted['properties'])
+ required = converted['required']
+ merge_extensions(
+ resource_type, schemas, properties, required
+ )
+ build_schema(properties, required)
+ end
+
+ def build_schema(properties, required)
+ {
+ 'type' => 'object',
+ 'properties' => properties,
+ 'required' => required,
+ 'additionalProperties' => false
+ }
+ end
+
+ def merge_extensions(resource_type, schemas, properties, required)
+ Array(resource_type[:schemaExtensions]).each do |extension|
+ extension_schema = schemas[extension[:schema]]
+ next unless extension_schema
+
+ properties[extension[:schema]] =
+ ScimSchemaConverter.convert(extension_schema)
+ required << extension[:schema] if extension[:required]
+ end
+ end
+
+ def fetch_schemas(urns)
+ uri = Cli.join_uri(base_url, 'Schemas')
+ result = http.fetch(uri, headers: headers)
+ return {} unless result.ok? && result.body.is_a?(Array)
+
+ result.body.each_with_object({}) do |schema, hash|
+ hash[schema[:id]] = schema if urns.include?(schema[:id])
+ end
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/resource_type_resolver.rb
@@ -10,14 +10,18 @@ module Scim
@headers = headers
end
- def endpoint_for(name)
+ def resource_type_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]
+ match
+ end
+
+ def endpoint_for(name)
+ endpoint = resource_type_for(name)[:endpoint]
raise MissingEndpoint, name if endpoint.to_s.empty?
endpoint
lib/scim/kit/cli/schema_registry.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ module SchemaRegistry
+ DIR = File.expand_path('schemas', __dir__)
+
+ FILES = {
+ service_provider_configuration:
+ 'service_provider_config.schema.json',
+ schemas: 'schemas.schema.json',
+ resource_types: 'resource_types.schema.json'
+ }.freeze
+
+ def self.fetch(key)
+ @cache ||= {}
+ @cache[key] ||= load(FILES.fetch(key))
+ end
+
+ def self.list_response_with_items(resource_schema)
+ schema = load('list_response.schema.json')
+ schema['properties']['Resources']['items'] = resource_schema
+ schema
+ end
+
+ def self.load(file_name)
+ JSON.parse(File.read(File.join(DIR, file_name)))
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/scim_schema_converter.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ module ScimSchemaConverter
+ TYPE_MAP = {
+ 'string' => { 'type' => 'string' },
+ 'reference' => { 'type' => 'string' },
+ 'binary' => { 'type' => 'string' },
+ 'boolean' => { 'type' => 'boolean' },
+ 'decimal' => { 'type' => 'number' },
+ 'integer' => { 'type' => 'integer' },
+ 'dateTime' => { 'type' => 'string', 'format' => 'date-time' }
+ }.freeze
+
+ def self.convert(schema)
+ object_schema(schema[:attributes] || [])
+ end
+
+ def self.object_schema(attributes)
+ properties, required = attributes.reduce([{}, []]) do |(p, r), a|
+ p[a[:name]] = attribute_schema(a)
+ r << a[:name] if a[:required]
+ [p, r]
+ end
+ { 'type' => 'object', 'properties' => properties,
+ 'required' => required, 'additionalProperties' => false }
+ end
+
+ def self.attribute_schema(attribute)
+ schema = leaf_schema(attribute)
+ return schema unless attribute[:multiValued]
+
+ { 'type' => 'array', 'items' => schema }
+ end
+
+ def self.leaf_schema(attribute)
+ if attribute[:type] == 'complex'
+ object_schema(attribute[:subAttributes] || [])
+ else
+ with_enum(attribute)
+ end
+ end
+
+ def self.with_enum(attribute)
+ schema = TYPE_MAP.fetch(attribute[:type]).dup
+ canonical_values = attribute[:canonicalValues]
+ return schema if canonical_values.nil? || canonical_values.empty?
+
+ schema.merge('enum' => canonical_values)
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/validator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ module Validator
+ def self.errors_for(schema, data)
+ JSONSchemer.schema(schema)
+ .validate(normalize(data))
+ .map { |error| JSONSchemer::Errors.pretty(error) }
+ end
+
+ def self.normalize(data)
+ JSON.parse(JSON.generate(data))
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli.rb
@@ -4,6 +4,11 @@ require 'thor'
require 'scim/kit/cli/reporting'
require 'scim/kit/cli/resource_type_resolver'
+require 'scim/kit/cli/scim_schema_converter'
+require 'json_schemer'
+require 'scim/kit/cli/schema_registry'
+require 'scim/kit/cli/validator'
+require 'scim/kit/cli/resource_schema_resolver'
require 'scim/kit/cli/app'
module Scim
spec/scim/kit/cli/app_spec.rb
@@ -90,6 +90,94 @@ RSpec.describe Scim::Kit::Cli::App do
expect(exit_status { app.discover }).to eq(1)
end
end
+
+ context 'when --validate is set and every document is valid' do
+ let(:service_provider_configuration) do
+ {
+ schemas: [
+ 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'
+ ],
+ patch: { supported: true },
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+ filter: { supported: true, maxResults: 200 },
+ changePassword: { supported: false },
+ sort: { supported: false },
+ etag: { supported: false },
+ authenticationSchemes: [
+ {
+ type: 'httpbasic', name: 'HTTP Basic',
+ description: 'basic auth'
+ }
+ ]
+ }
+ end
+ let(:schemas) do
+ [
+ {
+ id: 'urn:ietf:params:scim:schemas:core:2.0:User',
+ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
+ attributes: [
+ { name: 'userName', type: 'string', required: true }
+ ]
+ }
+ ]
+ end
+ let(:resource_types) do
+ [
+ {
+ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
+ name: 'User', endpoint: '/Users',
+ schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
+ }
+ ]
+ end
+
+ 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)
+ stub_request(:get, "#{base_url}/ResourceTypes")
+ .to_return(status: 200, body: resource_types.to_json)
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.discover }).to eq(0)
+ end
+ end
+
+ context 'when --validate is set and a document is invalid' do
+ let(:service_provider_configuration) { { patch: { supported: true } } }
+ let(:schemas) { [{ id: 'User', name: 'User' }] }
+
+ 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
+
+ it 'prints validation errors to stderr' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect { exit_status { instance.discover } }
+ .to output(/validation_errors/).to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.discover }).to eq(1)
+ end
+ end
end
describe '#list' do
@@ -152,6 +240,109 @@ RSpec.describe Scim::Kit::Cli::App do
expect(exit_status { app.list('User') }).to eq(1)
end
end
+
+ context 'when --validate is set' do
+ let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+ let(:resource_types) do
+ [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
+ end
+ let(:schemas_response) do
+ [
+ {
+ id: core_urn,
+ attributes: [
+ { name: 'userName', type: 'string', required: true }
+ ]
+ }
+ ]
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: schemas_response.to_json)
+ end
+
+ context 'when the list response is valid' do
+ let(:list_response) do
+ {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 1, Resources: [{ id: '1', userName: 'bjensen' }]
+ }
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Users")
+ .to_return(status: 200, body: list_response.to_json)
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.list('User') }).to eq(0)
+ end
+ end
+
+ context 'when a resource in the list response is invalid' do
+ let(:list_response) do
+ {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 1, Resources: [{ id: '1', userName: 42 }]
+ }
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Users")
+ .to_return(status: 200, body: list_response.to_json)
+ end
+
+ it 'prints validation errors to stderr' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect { exit_status { instance.list('User') } }
+ .to output(/validation_errors/).to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.list('User') }).to eq(1)
+ end
+ end
+
+ context 'when the resource type has no resolvable schema' do
+ let(:resource_types) do
+ [
+ { id: 'User', name: 'User', endpoint: '/Users',
+ schema: 'urn:example:Unresolvable' }
+ ]
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Users")
+ .to_return(status: 200, body: { totalResults: 0 }.to_json)
+ end
+
+ it 'warns and exits 0' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect { exit_status { instance.list('User') } }
+ .to output(/no schema found/).to_stderr
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.list('User') }).to eq(0)
+ end
+ end
+ end
end
describe '#get' do
@@ -196,6 +387,67 @@ RSpec.describe Scim::Kit::Cli::App do
expect(exit_status { app.get('User', '123') }).to eq(1)
end
end
+
+ context 'when --validate is set' do
+ let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+ let(:resource_types) do
+ [{ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }]
+ end
+ let(:schemas_response) do
+ [
+ {
+ id: core_urn,
+ attributes: [
+ { name: 'userName', type: 'string', required: true }
+ ]
+ }
+ ]
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: schemas_response.to_json)
+ end
+
+ context 'when the resource is valid' do
+ before do
+ stub_request(:get, "#{base_url}/Users/123").to_return(
+ status: 200, body: { id: '123', userName: 'bjensen' }.to_json
+ )
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.get('User', '123') }).to eq(0)
+ end
+ end
+
+ context 'when the resource is invalid' do
+ before do
+ stub_request(:get, "#{base_url}/Users/123").to_return(
+ status: 200, body: { id: '123', userName: 42 }.to_json
+ )
+ end
+
+ it 'prints validation errors to stderr' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect { exit_status { instance.get('User', '123') } }
+ .to output(/validation_errors/).to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+ instance = app('validate' => true)
+
+ expect(exit_status { instance.get('User', '123') }).to eq(1)
+ end
+ end
+ end
end
describe 'header parsing' do
spec/scim/kit/cli/reporting_spec.rb
@@ -48,6 +48,57 @@ RSpec.describe Scim::Kit::Cli::Reporting do
end
end
+ describe '.report_with_validation' do
+ let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
+
+ context 'when there are no errors' do
+ it 'prints the body as pretty json to stdout' do
+ expect do
+ exit_status do
+ described_class.report_with_validation(result, shell, [])
+ end
+ end.to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
+ end
+
+ it 'exits 0' do
+ allow($stdout).to receive(:print)
+
+ status = exit_status do
+ described_class.report_with_validation(result, shell, [])
+ end
+
+ expect(status).to eq(0)
+ end
+ end
+
+ context 'when there are errors' do
+ let(:errors) { ['root is missing required keys: userName'] }
+
+ it 'prints the body to stdout and the errors to stderr' do
+ allow($stdout).to receive(:print)
+
+ expect { exit_status { described_class.report_with_validation(result, shell, errors) } }
+ .to output("#{JSON.pretty_generate(validation_errors: errors)}\n").to_stderr
+ end
+
+ it 'exits 1' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+
+ status = exit_status { described_class.report_with_validation(result, shell, errors) }
+
+ expect(status).to eq(1)
+ end
+ end
+ end
+
+ describe '.warn_unresolvable_schema' do
+ it 'prints a warning mentioning the resource type to stderr' do
+ expect { described_class.warn_unresolvable_schema('Foo', shell) }
+ .to output(/no schema found for resource type "Foo"/).to_stderr
+ 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')
spec/scim/kit/cli/resource_schema_resolver_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
+ subject { described_class.new(Scim::Kit::Http.new, base_url, headers: {}) }
+
+ let(:base_url) { FFaker::Internet.uri('https') }
+ let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
+ let(:extension_urn) do
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
+ end
+
+ let(:resource_type) do
+ {
+ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn,
+ schemaExtensions: [{ schema: extension_urn, required: true }]
+ }
+ end
+
+ let(:core_schema) do
+ {
+ id: core_urn,
+ attributes: [{ name: 'userName', type: 'string', required: true }]
+ }
+ end
+
+ let(:extension_schema) do
+ {
+ id: extension_urn,
+ attributes: [{ name: 'employeeNumber', type: 'string' }]
+ }
+ end
+
+ describe '#schema_for' do
+ context 'when the core and extension schemas are both found' do
+ let(:schema) { subject.schema_for(resource_type) }
+ let(:expected_extension_schema) do
+ {
+ 'type' => 'object',
+ 'properties' => { 'employeeNumber' => { 'type' => 'string' } },
+ 'required' => [],
+ 'additionalProperties' => false
+ }
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Schemas").to_return(
+ status: 200, body: [core_schema, extension_schema].to_json
+ )
+ end
+
+ it 'includes the core attributes at the top level' do
+ expect(schema['properties']['userName']).to eql('type' => 'string')
+ end
+
+ it 'includes userName in the required list' do
+ expect(schema['required']).to include('userName')
+ end
+
+ it 'includes common attributes' do
+ expect(schema['properties']).to include('id', 'meta', 'schemas')
+ end
+
+ it 'nests extension attributes under the extension URN' do
+ expect(schema['properties'][extension_urn]).to eql(expected_extension_schema)
+ end
+
+ it 'requires the extension URN when the extension is required' do
+ expect(schema['required']).to include(extension_urn)
+ end
+
+ it 'disallows undeclared top-level properties' do
+ expect(schema['additionalProperties']).to be(false)
+ end
+ end
+
+ context 'when the extension schema is not found' do
+ let(:schema) { subject.schema_for(resource_type) }
+
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: [core_schema].to_json)
+ end
+
+ it 'includes the core attributes' do
+ expect(schema['properties']).to include('userName')
+ end
+
+ it 'excludes the extension URN from the properties' do
+ expect(schema['properties']).not_to include(extension_urn)
+ end
+ end
+
+ context 'when the core schema is not found' do
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: [extension_schema].to_json)
+ end
+
+ it 'returns nil' do
+ expect(subject.schema_for(resource_type)).to be_nil
+ end
+ end
+
+ context 'when the /Schemas request fails' do
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 500, body: '{}')
+ end
+
+ it 'returns nil' do
+ expect(subject.schema_for(resource_type)).to be_nil
+ end
+ end
+
+ context 'when the resource type has no extensions' do
+ let(:resource_type) do
+ { id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: [core_schema].to_json)
+ end
+
+ it 'returns a schema without extension properties' do
+ schema = subject.schema_for(resource_type)
+
+ expect(schema['properties'].keys).to match_array(
+ %w[schemas id externalId meta userName]
+ )
+ end
+ end
+ end
+end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -38,6 +38,31 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
end
end
+ describe '#resource_type_for' do
+ before do
+ stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+ status: 200,
+ body: [
+ { id: 'User', name: 'User', endpoint: '/Users',
+ schema: 'urn:ietf:params:scim:schemas:core:2.0:User' }
+ ].to_json
+ )
+ end
+
+ it 'returns the full matched resource type entry' do
+ expect(subject.resource_type_for('User')).to include(
+ endpoint: '/Users',
+ schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
+ )
+ end
+
+ it 'raises when no resource type matches the given name' do
+ expect { subject.resource_type_for('Nope') }.to raise_error(
+ Scim::Kit::Cli::UnknownResourceType, /Nope/
+ )
+ end
+ end
+
context 'when the matched resource type has no endpoint' do
before do
stub_request(:get, "#{base_url}/ResourceTypes").to_return(
spec/scim/kit/cli/schema_registry_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::SchemaRegistry do
+ describe '.fetch' do
+ it 'loads the service_provider_configuration schema' do
+ schema = described_class.fetch(:service_provider_configuration)
+
+ expect(schema['required']).to include('patch', 'bulk')
+ end
+
+ it 'loads the resource_types schema' do
+ schema = described_class.fetch(:resource_types)
+
+ expect(schema['items']['required']).to include('endpoint')
+ end
+
+ it 'loads the schemas schema' do
+ schema = described_class.fetch(:schemas)
+
+ expect(schema['items']['required']).to include('attributes')
+ end
+ end
+
+ describe '.list_response_with_items' do
+ it 'injects the given schema as the Resources items schema' do
+ item_schema = { 'type' => 'object' }
+
+ schema = described_class.list_response_with_items(item_schema)
+
+ expect(schema['properties']['Resources']['items']).to eql(item_schema)
+ end
+
+ it 'returns independent schema objects across calls' do
+ first = described_class.list_response_with_items({ 'type' => 'object' })
+ first['properties']['Resources']['items']['type'] = 'mutated'
+
+ second = described_class.list_response_with_items({ 'type' => 'string' })
+
+ expect(second['properties']['Resources']['items']).to eql('type' => 'string')
+ end
+ end
+
+ describe 'built-in schemas are valid JSON Schema documents' do
+ it 'validates service_provider_configuration' do
+ schema = described_class.fetch(:service_provider_configuration)
+
+ expect(JSONSchemer.valid_schema?(schema)).to be(true)
+ end
+
+ it 'validates resource_types' do
+ schema = described_class.fetch(:resource_types)
+
+ expect(JSONSchemer.valid_schema?(schema)).to be(true)
+ end
+
+ it 'validates schemas' do
+ schema = described_class.fetch(:schemas)
+
+ expect(JSONSchemer.valid_schema?(schema)).to be(true)
+ end
+
+ it 'validates list_response' do
+ schema = described_class.list_response_with_items({})
+
+ expect(JSONSchemer.valid_schema?(schema)).to be(true)
+ end
+ end
+end
spec/scim/kit/cli/scim_schema_converter_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
+ describe '.convert' do
+ context 'with a string attribute' do
+ let(:schema) { { attributes: [{ name: 'userName', type: 'string' }] } }
+ let(:expected_schema) do
+ {
+ 'type' => 'object',
+ 'properties' => { 'userName' => { 'type' => 'string' } },
+ 'required' => [],
+ 'additionalProperties' => false
+ }
+ end
+
+ it 'maps a string attribute' do
+ result = described_class.convert(schema)
+
+ expect(result).to eql(expected_schema)
+ end
+ end
+
+ context 'with a required attribute' do
+ let(:schema) do
+ {
+ attributes: [
+ { name: 'userName', type: 'string', required: true }
+ ]
+ }
+ end
+
+ it 'marks required attributes' do
+ result = described_class.convert(schema)
+
+ expect(result['required']).to eql(['userName'])
+ end
+ end
+
+ context 'with a multiValued attribute' do
+ let(:schema) do
+ {
+ attributes: [
+ { name: 'emails', type: 'string', multiValued: true }
+ ]
+ }
+ end
+
+ it 'wraps multiValued attributes in an array schema' do
+ result = described_class.convert(schema)
+
+ expect(result['properties']['emails']).to eql('type' => 'array', 'items' => { 'type' => 'string' })
+ end
+ end
+
+ context 'with canonicalValues' do
+ let(:schema) do
+ {
+ attributes: [
+ { name: 'type', type: 'string', canonicalValues: %w[work home] }
+ ]
+ }
+ end
+
+ it 'maps canonicalValues to enum' do
+ result = described_class.convert(schema)
+
+ expect(result['properties']['type']).to eql('type' => 'string', 'enum' => %w[work home])
+ end
+ end
+
+ context 'with a complex attribute containing subAttributes' do
+ let(:schema) do
+ {
+ attributes: [
+ {
+ name: 'name', type: 'complex',
+ subAttributes: [
+ { name: 'givenName', type: 'string', required: true },
+ { name: 'familyName', type: 'string' }
+ ]
+ }
+ ]
+ }
+ end
+ let(:expected_name_schema) do
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'givenName' => { 'type' => 'string' },
+ 'familyName' => { 'type' => 'string' }
+ },
+ 'required' => ['givenName'],
+ 'additionalProperties' => false
+ }
+ end
+
+ it 'maps complex attributes with nested subAttributes' do
+ result = described_class.convert(schema)
+
+ expect(result['properties']['name']).to eql(expected_name_schema)
+ end
+ end
+
+ context 'with every scalar SCIM type' do
+ let(:types) do
+ {
+ 'boolean' => { 'type' => 'boolean' },
+ 'decimal' => { 'type' => 'number' },
+ 'integer' => { 'type' => 'integer' },
+ 'dateTime' => { 'type' => 'string', 'format' => 'date-time' },
+ 'reference' => { 'type' => 'string' },
+ 'binary' => { 'type' => 'string' }
+ }
+ end
+
+ it 'maps every scalar SCIM type to its JSON Schema equivalent' do
+ types.each do |scim_type, json_schema|
+ schema = { attributes: [{ name: 'x', type: scim_type }] }
+ result = described_class.convert(schema)
+
+ expect(result['properties']['x']).to eql(json_schema)
+ end
+ end
+ end
+
+ it 'defaults to no attributes when attributes is missing' do
+ result = described_class.convert({})
+
+ expect(result['properties']).to eql({})
+ end
+ end
+end
spec/scim/kit/cli/validator_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Validator do
+ describe '.errors_for' do
+ let(:schema) do
+ {
+ 'type' => 'object',
+ 'properties' => { 'userName' => { 'type' => 'string' } },
+ 'required' => ['userName'],
+ 'additionalProperties' => false
+ }
+ end
+
+ it 'returns an empty array for a valid document' do
+ errors = described_class.errors_for(schema, { userName: 'bjensen' })
+
+ expect(errors).to eql([])
+ end
+
+ it 'returns a readable error for a missing required property' do
+ errors = described_class.errors_for(schema, {})
+
+ expect(errors).to eql(['root is missing required keys: userName'])
+ end
+
+ it 'returns a readable error for a wrong type' do
+ errors = described_class.errors_for(schema, { userName: 1 })
+
+ expect(errors).to eql(["property '/userName' is not of type: string"])
+ end
+
+ it 'returns a readable error for an undeclared property' do
+ errors = described_class.errors_for(schema, { userName: 'bjensen', extra: true })
+
+ expect(errors).to eql(["property '/extra' is invalid: error_type=schema"])
+ end
+ end
+end
.rubocop.yml
@@ -51,6 +51,7 @@ Lint/StructNewOverride:
Metrics/AbcSize:
Exclude:
- 'lib/scim/kit/v2/service_provider_configuration.rb'
+ - 'lib/scim/kit/cli/app.rb'
Metrics/BlockLength:
Exclude:
@@ -58,6 +59,14 @@ Metrics/BlockLength:
- 'Rakefile'
- 'spec/**/*.rb'
+Metrics/ClassLength:
+ Exclude:
+ - 'lib/scim/kit/cli/app.rb'
+
+Metrics/MethodLength:
+ Exclude:
+ - 'lib/scim/kit/cli/app.rb'
+
Metrics/ModuleLength:
Exclude:
- 'spec/**/*.rb'
CHANGELOG.md
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add a `scim-kit` CLI with `discover`, `list`, and `get` commands for
reading a remote SCIM server's configuration and resources.
+- Add a `--validate` flag to `discover`, `list`, and `get` for checking
+ responses against a JSON Schema (built-in RFC 7643 schemas for
+ `discover`; derived from the target server's own `/Schemas` for
+ `list`/`get`).
## [0.8.0] - 2026-03-31
### Changed
Gemfile.lock
@@ -3,6 +3,7 @@ PATH
specs:
scim-kit (0.8.0)
activemodel (>= 6.1)
+ json_schemer (~> 2.0)
net-hippie (~> 1.0)
parslet (~> 2.0)
thor (~> 1.0)
@@ -53,6 +54,7 @@ GEM
drb (2.2.1)
erubi (1.13.0)
ffaker (2.23.0)
+ hana (1.3.7)
hashdiff (1.1.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
@@ -60,6 +62,11 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.9.0)
+ json_schemer (2.5.0)
+ bigdecimal
+ hana (~> 1.3)
+ regexp_parser (~> 2.0)
+ simpleidn (~> 0.2)
language_server-protocol (3.17.0.3)
logger (1.6.2)
loofah (2.23.1)
@@ -141,6 +148,7 @@ GEM
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
securerandom (0.4.0)
+ simpleidn (0.2.3)
thor (1.5.0)
tilt (2.4.0)
tilt-jbuilder (0.7.1)
scim-kit.gemspec
@@ -31,6 +31,7 @@ Gem::Specification.new do |spec|
spec.metadata['yard.run'] = 'yri'
spec.add_dependency 'activemodel', '>= 6.1'
+ spec.add_dependency 'json_schemer', '~> 2.0'
spec.add_dependency 'net-hippie', '~> 1.0'
spec.add_dependency 'parslet', '~> 2.0'
spec.add_dependency 'thor', '~> 1.0'