Commit 78367f8
Changed files (27)
lib
scim
kit
spec
lib/scim/kit/cli/schemas/list_response.schema.json
@@ -8,6 +8,5 @@
"startIndex": { "type": "integer" },
"Resources": { "type": "array", "items": {} }
},
- "required": ["schemas", "totalResults"],
- "additionalProperties": false
+ "required": ["schemas", "totalResults"]
}
lib/scim/kit/cli/schemas/resource_types.schema.json
@@ -25,8 +25,7 @@
"schema": { "type": "string" },
"required": { "type": "boolean" }
},
- "required": ["schema", "required"],
- "additionalProperties": false
+ "required": ["schema", "required"]
}
},
"externalId": { "type": "string" },
@@ -38,16 +37,12 @@
"lastModified": { "type": "string", "format": "date-time" },
"location": { "type": "string" },
"version": { "type": "string" }
- },
- "required": [],
- "additionalProperties": false
+ }
}
},
- "required": ["schemas", "name", "endpoint", "schema"],
- "additionalProperties": false
+ "required": ["schemas", "name", "endpoint", "schema"]
}
}
},
- "required": ["schemas", "totalResults", "Resources"],
- "additionalProperties": false
+ "required": ["schemas", "totalResults", "Resources"]
}
lib/scim/kit/cli/schemas/schemas.schema.json
@@ -22,13 +22,11 @@
"externalId": { "type": "string" },
"meta": { "$ref": "#/$defs/meta" }
},
- "required": ["schemas", "id", "attributes"],
- "additionalProperties": false
+ "required": ["schemas", "id", "attributes"]
}
}
},
"required": ["schemas", "totalResults", "Resources"],
- "additionalProperties": false,
"$defs": {
"attribute": {
"type": "object",
@@ -64,8 +62,7 @@
"items": { "$ref": "#/$defs/attribute" }
}
},
- "required": ["name", "type"],
- "additionalProperties": false
+ "required": ["name", "type"]
},
"meta": {
"type": "object",
@@ -75,9 +72,7 @@
"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
@@ -9,8 +9,7 @@
"patch": {
"type": "object",
"properties": { "supported": { "type": "boolean" } },
- "required": ["supported"],
- "additionalProperties": false
+ "required": ["supported"]
},
"bulk": {
"type": "object",
@@ -19,8 +18,7 @@
"maxOperations": { "type": "integer" },
"maxPayloadSize": { "type": "integer" }
},
- "required": ["supported", "maxOperations", "maxPayloadSize"],
- "additionalProperties": false
+ "required": ["supported", "maxOperations", "maxPayloadSize"]
},
"filter": {
"type": "object",
@@ -28,26 +26,22 @@
"supported": { "type": "boolean" },
"maxResults": { "type": "integer" }
},
- "required": ["supported", "maxResults"],
- "additionalProperties": false
+ "required": ["supported", "maxResults"]
},
"changePassword": {
"type": "object",
"properties": { "supported": { "type": "boolean" } },
- "required": ["supported"],
- "additionalProperties": false
+ "required": ["supported"]
},
"sort": {
"type": "object",
"properties": { "supported": { "type": "boolean" } },
- "required": ["supported"],
- "additionalProperties": false
+ "required": ["supported"]
},
"etag": {
"type": "object",
"properties": { "supported": { "type": "boolean" } },
- "required": ["supported"],
- "additionalProperties": false
+ "required": ["supported"]
},
"authenticationSchemes": {
"type": "array",
@@ -61,8 +55,7 @@
"documentationUri": { "type": "string" },
"primary": { "type": "boolean" }
},
- "required": ["type", "name", "description"],
- "additionalProperties": false
+ "required": ["type", "name", "description"]
}
},
"meta": {
@@ -73,14 +66,11 @@
"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
@@ -20,7 +20,7 @@ module Scim
desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
def discover
- Reporting.rescue_errors(shell) { fetch_discovery }
+ run { fetch_discovery }
end
desc 'list RESOURCE_TYPE', 'List resources of a given type'
@@ -31,22 +31,30 @@ module Scim
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) }
+ run { 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) }
+ run { fetch_resource(resource_type, id) }
end
private
+ def run
+ exit(yield)
+ rescue RequestFailed => error
+ exit(reporter.failure(error.result.body))
+ rescue Error => error
+ exit(reporter.failure(detail: error.message))
+ end
+
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?
+ result = client.fetch(path)
+ return reporter.report(result) unless result.ok?
responses[key] = result.body
end
@@ -54,13 +62,9 @@ module Scim
end
def report_discovery(combined)
- if options[:validate]
- Reporting.report_with_validation(
- combined, shell, discovery_errors(combined.body)
- )
- else
- Reporting.report(combined, shell)
- end
+ return reporter.report(combined) unless options[:validate]
+
+ reporter.report_validation(combined, discovery_errors(combined.body))
end
def discovery_errors(responses)
@@ -72,21 +76,19 @@ module Scim
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?
- result = http.fetch(uri, headers: headers)
+ endpoint = resolve_endpoint(resource_type)
+ result = client.fetch(endpoint, query: list_query)
validate_and_report(result, resource_type) do |schema|
SchemaRegistry.list_response_with_items(schema)
end
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
- result = http.fetch(uri, headers: headers)
+ 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)
end
@@ -98,52 +100,76 @@ module Scim
end
def resource_type_entry(resource_type)
- @resource_type_entry ||=
- resource_type_resolver.resource_type_for(resource_type)
+ resource_type_resolver.resource_type_for(resource_type)
end
def resource_type_resolver
- @resource_type_resolver ||= ResourceTypeResolver.new(
- http, url, headers: headers
- )
+ @resource_type_resolver ||= ResourceTypeResolver.new(client)
end
def resource_schema_resolver
- @resource_schema_resolver ||= ResourceSchemaResolver.new(
- http, url, headers: headers
- )
+ @resource_schema_resolver ||= ResourceSchemaResolver.new(client)
end
- def validate_and_report(result, resource_type)
- unless result.ok? && options[:validate]
- return Reporting.report(result, shell)
- end
+ 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)
- unless schema
- Reporting.warn_unresolvable_schema(resource_type, shell)
- return Reporting.report(result, shell)
+ if schema
+ warn_undeclared_extensions
+ else
+ warn_unresolvable_schema(resource_type)
end
+ schema
+ end
- schema = yield(schema) if block_given?
- errors = Validator.errors_for(schema, result.body)
- Reporting.report_with_validation(result, shell, errors)
- 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
+ def prepare(schema)
+ schema = SparseSchema.relax(schema) if options[:attributes]
+ block_given? ? yield(schema) : schema
+ end
+
+ def warn_unresolvable_schema(resource_type)
+ reporter.warn(
+ "no schema found for resource type #{resource_type.inspect}; " \
+ 'skipping validation'
)
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)
@@ -157,15 +183,17 @@ module Scim
@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\")"
+ 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
+ def client
+ @client ||= Client.new(url, headers: headers)
end
end
end
lib/scim/kit/cli/client.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ class Client
+ def initialize(base_url, headers: {}, http: Scim::Kit::Http.new)
+ @base_url = base_url
+ @headers = headers
+ @http = http
+ end
+
+ def fetch(path, query: {})
+ http.fetch(uri_for(path, query), headers: headers)
+ end
+
+ private
+
+ attr_reader :base_url, :headers, :http
+
+ def uri_for(path, query)
+ uri = URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
+ encoded = URI.encode_www_form(query.compact)
+ uri.query = encoded unless encoded.empty?
+ uri
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/reporter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ class Reporter
+ SUCCESS = 0
+ FAILURE = 1
+
+ def initialize(shell)
+ @shell = shell
+ end
+
+ def report(result)
+ result.ok? ? success(result.body) : failure(result.body)
+ end
+
+ def report_validation(result, errors)
+ shell.say(pretty(result.body))
+ return SUCCESS if errors.empty?
+
+ failure(validation_errors: errors)
+ end
+
+ def success(body)
+ shell.say(pretty(body))
+ SUCCESS
+ end
+
+ def failure(body)
+ shell.say_error(pretty(body))
+ FAILURE
+ end
+
+ def warn(message)
+ shell.say_error("warning: #{message}")
+ end
+
+ private
+
+ attr_reader :shell
+
+ def pretty(body)
+ JSON.pretty_generate(body)
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/reporting.rb
@@ -1,51 +0,0 @@
-# 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.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
- report(error.result, shell)
- rescue UnknownResourceType, MissingEndpoint, InvalidResponse => error
- report_error(error.message, shell)
- end
- end
- end
- end
-end
lib/scim/kit/cli/resource_schema_resolver.rb
@@ -21,15 +21,17 @@ module Scim
'location' => { 'type' => 'string' },
'version' => { 'type' => 'string' }
},
- 'required' => [],
- 'additionalProperties' => false
+ 'required' => ['resourceType']
}
}.freeze
- def initialize(http, base_url, headers: {})
- @http = http
- @base_url = base_url
- @headers = headers
+ COMMON_REQUIRED = %w[schemas id].freeze
+
+ attr_reader :undeclared_extensions
+
+ def initialize(client)
+ @client = client
+ @undeclared_extensions = []
end
def schema_for(resource_type)
@@ -42,7 +44,7 @@ module Scim
private
- attr_reader :http, :base_url, :headers
+ attr_reader :client
def urns_for(resource_type)
extensions = Array(resource_type[:schemaExtensions])
@@ -66,15 +68,17 @@ module Scim
{
'type' => 'object',
'properties' => properties,
- 'required' => required,
- 'additionalProperties' => false
+ 'required' => COMMON_REQUIRED | required
}
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
+ unless extension_schema
+ undeclared_extensions << extension[:schema]
+ next
+ end
properties[extension[:schema]] =
ScimSchemaConverter.convert(extension_schema)
@@ -83,8 +87,7 @@ module Scim
end
def fetch_schemas(urns)
- uri = Cli.join_uri(base_url, 'Schemas')
- result = http.fetch(uri, headers: headers)
+ result = client.fetch('Schemas')
schemas = result.ok? ? Cli.collection(result.body) : nil
return {} if schemas.nil?
lib/scim/kit/cli/resource_type_resolver.rb
@@ -4,10 +4,8 @@ module Scim
module Kit
module Cli
class ResourceTypeResolver
- def initialize(http, base_url, headers: {})
- @http = http
- @base_url = base_url
- @headers = headers
+ def initialize(client)
+ @client = client
end
def resource_type_for(name)
@@ -22,15 +20,18 @@ module Scim
private
- attr_reader :http, :base_url, :headers
+ attr_reader :client
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)
+ @resource_types ||= fetch_resource_types
+ end
+
+ def fetch_resource_types
+ result = client.fetch('ResourceTypes')
raise RequestFailed, result unless result.ok?
types = Cli.collection(result.body)
lib/scim/kit/cli/schema_registry.rb
@@ -5,6 +5,7 @@ module Scim
module Cli
module SchemaRegistry
DIR = File.expand_path('schemas', __dir__)
+ LIST_RESPONSE = 'list_response.schema.json'
FILES = {
service_provider_configuration:
@@ -13,19 +14,30 @@ module Scim
resource_types: 'resource_types.schema.json'
}.freeze
- def self.fetch(key)
- @cache ||= {}
- @cache[key] ||= load_schema(FILES.fetch(key))
- end
+ class << self
+ def fetch(key)
+ load_schema(FILES.fetch(key))
+ end
- def self.list_response_with_items(resource_schema)
- schema = load_schema('list_response.schema.json')
- schema['properties']['Resources']['items'] = resource_schema
- schema
- end
+ def list_response_with_items(resource_schema)
+ schema = load_schema(LIST_RESPONSE)
+ schema['properties']['Resources']['items'] = resource_schema
+ schema
+ end
+
+ private
+
+ def load_schema(file_name)
+ JSON.parse(sources[file_name] ||= read(file_name))
+ end
+
+ def read(file_name)
+ File.read(File.join(DIR, file_name))
+ end
- def self.load_schema(file_name)
- JSON.parse(File.read(File.join(DIR, file_name)))
+ def sources
+ @sources ||= {}
+ end
end
end
end
lib/scim/kit/cli/scim_schema_converter.rb
@@ -14,48 +14,53 @@ module Scim
'dateTime' => { 'type' => 'string', 'format' => 'date-time' }
}.each_value(&:freeze).freeze
- def self.convert(schema)
- object_schema(schema[:attributes] || [])
- end
+ class << self
+ def convert(schema)
+ object_schema(schema[:attributes] || [])
+ end
- def self.object_schema(attributes)
- {
- 'type' => 'object',
- 'properties' => properties_for(attributes),
- 'required' => required_for(attributes),
- 'additionalProperties' => false
- }
- end
+ private
- def self.properties_for(attributes)
- attributes.to_h { |a| [a[:name], attribute_schema(a)] }
- end
+ def object_schema(attributes)
+ {
+ 'type' => 'object',
+ 'properties' => properties_for(attributes),
+ 'required' => required_for(attributes)
+ }
+ end
- def self.required_for(attributes)
- attributes.filter_map { |a| a[:name] if a[:required] }
- end
+ def properties_for(attributes)
+ attributes.to_h { |a| [a[:name], attribute_schema(a)] }
+ end
- def self.attribute_schema(attribute)
- schema = leaf_schema(attribute)
- return schema unless attribute[:multiValued]
+ def required_for(attributes)
+ attributes.filter_map do |a|
+ a[:name] if a[:required] && a[:returned] != 'never'
+ end
+ end
- { 'type' => 'array', 'items' => schema }
- end
+ def attribute_schema(attribute)
+ schema = leaf_schema(attribute)
+ return schema unless attribute[:multiValued]
- def self.leaf_schema(attribute)
- if attribute[:type] == 'complex'
- object_schema(attribute[:subAttributes] || [])
- else
- with_enum(attribute)
+ { 'type' => 'array', 'items' => schema }
end
- end
- def self.with_enum(attribute)
- schema = TYPE_MAP.fetch(attribute[:type])
- canonical_values = attribute[:canonicalValues]
- return schema if canonical_values.nil? || canonical_values.empty?
+ def leaf_schema(attribute)
+ if attribute[:type] == 'complex'
+ object_schema(attribute[:subAttributes] || [])
+ else
+ with_enum(attribute)
+ end
+ end
+
+ def with_enum(attribute)
+ schema = TYPE_MAP.fetch(attribute[:type]) { {} }
+ canonical_values = attribute[:canonicalValues]
+ return schema if canonical_values.nil? || canonical_values.empty?
- schema.merge('enum' => canonical_values)
+ schema.merge('enum' => canonical_values)
+ end
end
end
end
lib/scim/kit/cli/sparse_schema.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Scim
+ module Kit
+ module Cli
+ module SparseSchema
+ ALWAYS_RETURNED = %w[schemas id].freeze
+
+ class << self
+ def relax(schema)
+ strip_required(schema).merge('required' => ALWAYS_RETURNED)
+ end
+
+ private
+
+ def strip_required(value)
+ case value
+ when Hash
+ value.except('required')
+ .transform_values { |v| strip_required(v) }
+ when Array
+ value.map { |v| strip_required(v) }
+ else
+ value
+ end
+ end
+ end
+ end
+ end
+ end
+end
lib/scim/kit/cli/validator.rb
@@ -4,14 +4,18 @@ 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
+ class << self
+ def errors_for(schema, data)
+ JSONSchemer.schema(schema)
+ .validate(normalize(data))
+ .map { |error| JSONSchemer::Errors.pretty(error) }
+ end
+
+ private
- def self.normalize(data)
- JSON.parse(JSON.generate(data))
+ def normalize(data)
+ JSON.parse(JSON.generate(data))
+ end
end
end
end
lib/scim/kit/cli.rb
@@ -4,11 +4,13 @@ require 'json_schemer'
require 'thor'
require 'scim/kit/cli/app'
-require 'scim/kit/cli/reporting'
+require 'scim/kit/cli/client'
+require 'scim/kit/cli/reporter'
require 'scim/kit/cli/resource_schema_resolver'
require 'scim/kit/cli/resource_type_resolver'
require 'scim/kit/cli/schema_registry'
require 'scim/kit/cli/scim_schema_converter'
+require 'scim/kit/cli/sparse_schema'
require 'scim/kit/cli/validator'
module Scim
@@ -39,10 +41,6 @@ module Scim
class InvalidResponse < Error; end
- def self.join_uri(base_url, path)
- URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
- end
-
# SCIM collection endpoints (/Schemas, /ResourceTypes) return a
# ListResponse per RFC 7644 section 4, but some servers return a
# bare array. Return the underlying array for either shape, or nil.
spec/scim/kit/cli/app_spec.rb
@@ -317,7 +317,10 @@ RSpec.describe Scim::Kit::Cli::App do
let(:list_response) do
{
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
- totalResults: 1, Resources: [{ id: '1', userName: 'bjensen' }]
+ totalResults: 1,
+ Resources: [
+ { schemas: [core_urn], id: '1', userName: 'bjensen' }
+ ]
}
end
@@ -334,6 +337,66 @@ RSpec.describe Scim::Kit::Cli::App do
end
end
+ context 'when --attributes narrows the response' do
+ before do
+ stub_request(:get, "#{base_url}/Users?attributes=id")
+ .to_return(
+ status: 200,
+ body: {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 1,
+ Resources: [{ schemas: [core_urn], id: '1' }]
+ }.to_json
+ )
+ end
+
+ it 'exits 0 without demanding attributes the server was not asked for' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true, 'attributes' => 'id')
+
+ expect(exit_status { instance.list('User') }).to eq(0)
+ end
+ end
+
+ context 'when the resource type declares an extension /Schemas omits' do
+ let(:extension_urn) { 'urn:vendor:2.0:Thing' }
+ let(:resource_types) do
+ [{
+ id: 'User', name: 'User', endpoint: '/Users', schema: core_urn,
+ schemaExtensions: [{ schema: extension_urn, required: true }]
+ }]
+ end
+
+ before do
+ stub_request(:get, "#{base_url}/Users").to_return(
+ status: 200,
+ body: {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 1,
+ Resources: [
+ { schemas: [core_urn], id: '1', userName: 'bjensen' }
+ ]
+ }.to_json
+ )
+ end
+
+ it 'warns about the undeclared extension' do
+ allow($stdout).to receive(:print)
+ instance = app('validate' => true)
+
+ expect { exit_status { instance.list('User') } }
+ .to output(/#{Regexp.escape(extension_urn)}/).to_stderr
+ end
+
+ it 'still 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
+
context 'when a resource in the list response is invalid' do
let(:list_response) do
{
@@ -421,6 +484,28 @@ RSpec.describe Scim::Kit::Cli::App do
end
end
+ context 'when the id needs escaping' do
+ it 'escapes a space rather than raising URI::InvalidURIError' do
+ stub = stub_request(:get, "#{base_url}/Users/mo%20khan")
+ .to_return(status: 200, body: {}.to_json)
+ allow($stdout).to receive(:print)
+
+ exit_status { app.get('User', 'mo khan') }
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'escapes separators so an id cannot traverse the endpoint' do
+ stub = stub_request(:get, "#{base_url}/Users/..%2Fadmin%23x%3Fy")
+ .to_return(status: 200, body: {}.to_json)
+ allow($stdout).to receive(:print)
+
+ exit_status { app.get('User', '../admin#x?y') }
+
+ expect(stub).to have_been_requested
+ end
+ end
+
context 'when the get request fails' do
before do
stub_request(:get, "#{base_url}/Users/123")
@@ -463,7 +548,10 @@ RSpec.describe Scim::Kit::Cli::App do
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
+ status: 200,
+ body: {
+ schemas: [core_urn], id: '123', userName: 'bjensen'
+ }.to_json
)
end
spec/scim/kit/cli/client_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Client do
+ let(:body) { { id: '1' }.to_json }
+
+ def client(base_url, headers: {})
+ described_class.new(base_url, headers: headers)
+ end
+
+ describe '#fetch' do
+ it 'joins a base url without a trailing slash to a path' do
+ stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+ .to_return(status: 200, body: body)
+
+ client('https://example.com/scim/v2').fetch('Users')
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'joins a base url with a trailing slash to a path' do
+ stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+ .to_return(status: 200, body: body)
+
+ client('https://example.com/scim/v2/').fetch('Users')
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'joins a base url with multiple trailing slashes to a path' do
+ stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+ .to_return(status: 200, body: body)
+
+ client('https://example.com/scim/v2///').fetch('Users')
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'appends the query string' do
+ stub = stub_request(:get, 'https://example.com/Users?count=2&filter=x')
+ .to_return(status: 200, body: body)
+
+ client('https://example.com')
+ .fetch('Users', query: { 'filter' => 'x', 'count' => 2 })
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'omits blank query values' do
+ stub = stub_request(:get, 'https://example.com/Users')
+ .to_return(status: 200, body: body)
+
+ client('https://example.com')
+ .fetch('Users', query: { 'filter' => nil, 'count' => nil })
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'sends the configured headers' do
+ auth = { 'Authorization' => 'Bearer xyz' }
+ stub = stub_request(:get, 'https://example.com/Users')
+ .with(headers: auth).to_return(status: 200, body: body)
+
+ client('https://example.com', headers: auth).fetch('Users')
+
+ expect(stub).to have_been_requested
+ end
+
+ it 'returns the parsed result' do
+ stub_request(:get, 'https://example.com/Users')
+ .to_return(status: 200, body: body)
+
+ result = client('https://example.com').fetch('Users')
+
+ expect(result.body).to eql(id: '1')
+ end
+ end
+end
spec/scim/kit/cli/reporter_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Reporter do
+ subject { described_class.new(Thor::Shell::Basic.new) }
+
+ let(:body) { { id: '123' } }
+ let(:pretty) { JSON.pretty_generate(body) }
+
+ describe '#report' do
+ context 'when the result is ok' do
+ let(:result) { Scim::Kit::Http::Result.new(200, body) }
+
+ it 'prints the body as pretty json to stdout' do
+ expect { subject.report(result) }.to output("#{pretty}\n").to_stdout
+ end
+
+ it 'returns a success status' do
+ allow($stdout).to receive(:print)
+
+ expect(subject.report(result)).to eq(0)
+ end
+ end
+
+ context 'when the result is not ok' do
+ let(:result) { Scim::Kit::Http::Result.new(404, body) }
+
+ it 'prints the body as pretty json to stderr' do
+ expect { subject.report(result) }.to output("#{pretty}\n").to_stderr
+ end
+
+ it 'returns a failure status' do
+ allow($stderr).to receive(:print)
+
+ expect(subject.report(result)).to eq(1)
+ end
+ end
+ end
+
+ describe '#report_validation' do
+ let(:result) { Scim::Kit::Http::Result.new(200, body) }
+
+ context 'without errors' do
+ it 'prints the body to stdout' do
+ expect { subject.report_validation(result, []) }
+ .to output("#{pretty}\n").to_stdout
+ end
+
+ it 'returns a success status' do
+ allow($stdout).to receive(:print)
+
+ expect(subject.report_validation(result, [])).to eq(0)
+ end
+ end
+
+ context 'with errors' do
+ let(:errors) { ['root is missing required keys: id'] }
+
+ it 'prints the errors to stderr' do
+ allow($stdout).to receive(:print)
+
+ expect { subject.report_validation(result, errors) }
+ .to output(/validation_errors/).to_stderr
+ end
+
+ it 'still prints the body to stdout' do
+ allow($stderr).to receive(:print)
+
+ expect { subject.report_validation(result, errors) }
+ .to output(/#{pretty}/).to_stdout
+ end
+
+ it 'returns a failure status' do
+ allow($stdout).to receive(:print)
+ allow($stderr).to receive(:print)
+
+ expect(subject.report_validation(result, errors)).to eq(1)
+ end
+ end
+ end
+
+ describe '#failure' do
+ it 'prints the body as pretty json to stderr' do
+ expect { subject.failure(detail: 'boom') }
+ .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+ end
+
+ it 'returns a failure status' do
+ allow($stderr).to receive(:print)
+
+ expect(subject.failure(detail: 'boom')).to eq(1)
+ end
+ end
+
+ describe '#success' do
+ it 'prints the body as pretty json to stdout' do
+ expect { subject.success(body) }.to output("#{pretty}\n").to_stdout
+ end
+
+ it 'returns a success status' do
+ allow($stdout).to receive(:print)
+
+ expect(subject.success(body)).to eq(0)
+ end
+ end
+
+ describe '#warn' do
+ it 'prefixes the message and writes it to stderr' do
+ expect { subject.warn('watch out') }
+ .to output("warning: watch out\n").to_stderr
+ end
+ end
+end
spec/scim/kit/cli/reporting_spec.rb
@@ -1,157 +0,0 @@
-# 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 '.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')
- 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_schema_resolver_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
- subject { described_class.new(Scim::Kit::Http.new, base_url, headers: {}) }
+ subject { described_class.new(Scim::Kit::Cli::Client.new(base_url)) }
let(:base_url) { FFaker::Internet.uri('https') }
let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
@@ -31,14 +31,25 @@ RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
end
describe '#schema_for' do
+ let(:schema) { subject.schema_for(resource_type) }
+ let(:resource) do
+ {
+ schemas: [core_urn], id: '1', userName: 'mo',
+ meta: { resourceType: 'User' },
+ extension_urn => { employeeNumber: '1' }
+ }
+ end
+
+ def errors_for(body)
+ Scim::Kit::Cli::Validator.errors_for(schema, body)
+ end
+
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
+ 'required' => []
}
end
@@ -68,8 +79,42 @@ RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
expect(schema['required']).to include(extension_urn)
end
- it 'disallows undeclared top-level properties' do
- expect(schema['additionalProperties']).to be(false)
+ it 'allows vendor properties the server adds' do
+ errors = errors_for(resource.merge('urn:vendor:custom' => { a: true }))
+
+ expect(errors).to be_empty
+ end
+
+ it 'requires the attributes RFC 7643 section 3.1 mandates' do
+ expect(errors_for(userName: 'mo'))
+ .to include(/missing required keys.*schemas/)
+ end
+
+ it 'requires id' do
+ expect(errors_for(userName: 'mo'))
+ .to include(/missing required keys.*id/)
+ end
+
+ it 'requires meta.resourceType when meta is returned' do
+ expect(errors_for(resource.merge(meta: {})))
+ .to include(/meta.*missing required keys.*resourceType/)
+ end
+ end
+
+ context 'when the resource type declares an extension /Schemas omits' do
+ before do
+ stub_request(:get, "#{base_url}/Schemas")
+ .to_return(status: 200, body: [core_schema].to_json)
+ end
+
+ it 'records the undeclared extension URN' do
+ subject.schema_for(resource_type)
+
+ expect(subject.undeclared_extensions).to eql([extension_urn])
+ end
+
+ it 'still accepts a resource carrying that extension' do
+ expect(errors_for(resource)).to be_empty
end
end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -1,7 +1,11 @@
# frozen_string_literal: true
RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
- subject { described_class.new(Scim::Kit::Http.new, base_url, headers: headers) }
+ subject do
+ described_class.new(
+ Scim::Kit::Cli::Client.new(base_url, headers: headers)
+ )
+ end
let(:base_url) { FFaker::Internet.uri('https') }
let(:headers) { {} }
@@ -29,6 +33,19 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
expect(subject.resource_type_for('user')).to include(endpoint: '/Users')
end
+ it 'resolves each name independently' do
+ subject.resource_type_for('User')
+
+ expect(subject.resource_type_for('Group')).to include(endpoint: '/Groups')
+ end
+
+ it 'fetches ResourceTypes once across repeated lookups' do
+ subject.resource_type_for('User')
+ subject.resource_type_for('Group')
+
+ expect(a_request(:get, "#{base_url}/ResourceTypes")).to have_been_made.once
+ 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/
spec/scim/kit/cli/scim_schema_converter_spec.rb
@@ -8,8 +8,7 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
{
'type' => 'object',
'properties' => { 'userName' => { 'type' => 'string' } },
- 'required' => [],
- 'additionalProperties' => false
+ 'required' => []
}
end
@@ -89,8 +88,7 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
'givenName' => { 'type' => 'string' },
'familyName' => { 'type' => 'string' }
},
- 'required' => ['givenName'],
- 'additionalProperties' => false
+ 'required' => ['givenName']
}
end
@@ -128,5 +126,35 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
expect(result['properties']).to eql({})
end
+
+ context 'with an attribute type the spec does not define' do
+ it 'leaves an unrecognized type unconstrained instead of raising' do
+ schema = { attributes: [{ name: 'x', type: 'Reference' }] }
+
+ expect(described_class.convert(schema)['properties']['x']).to eql({})
+ end
+
+ it 'leaves a missing type unconstrained instead of raising' do
+ schema = { attributes: [{ name: 'x' }] }
+
+ expect(described_class.convert(schema)['properties']['x']).to eql({})
+ end
+ end
+
+ context 'with a required attribute the server never returns' do
+ let(:schema) do
+ {
+ attributes: [
+ { name: 'password', type: 'string', required: true,
+ returned: 'never' },
+ { name: 'userName', type: 'string', required: true }
+ ]
+ }
+ end
+
+ it 'excludes it from required' do
+ expect(described_class.convert(schema)['required']).to eql(['userName'])
+ end
+ end
end
end
spec/scim/kit/cli/sparse_schema_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::SparseSchema do
+ describe '.relax' do
+ let(:schema) do
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'schemas' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
+ 'id' => { 'type' => 'string' },
+ 'userName' => { 'type' => 'string' },
+ 'meta' => {
+ 'type' => 'object',
+ 'properties' => { 'resourceType' => { 'type' => 'string' } },
+ 'required' => ['resourceType']
+ },
+ 'emails' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'object',
+ 'properties' => { 'value' => { 'type' => 'string' } },
+ 'required' => ['value']
+ }
+ }
+ },
+ 'required' => %w[schemas id userName]
+ }
+ end
+
+ it 'requires only the always-returned attributes' do
+ expect(described_class.relax(schema)['required']).to eql(%w[schemas id])
+ end
+
+ it 'strips required from nested objects' do
+ relaxed = described_class.relax(schema)
+
+ expect(relaxed['properties']['meta']).not_to include('required')
+ end
+
+ it 'strips required from array items' do
+ relaxed = described_class.relax(schema)
+
+ expect(relaxed['properties']['emails']['items'])
+ .not_to include('required')
+ end
+
+ it 'leaves the original schema untouched' do
+ described_class.relax(schema)
+
+ expect(schema['required']).to eql(%w[schemas id userName])
+ end
+
+ it 'keeps type constraints' do
+ relaxed = described_class.relax(schema)
+
+ expect(relaxed['properties']['userName']).to eql('type' => 'string')
+ end
+
+ it 'accepts a resource carrying only the requested attributes' do
+ errors = Scim::Kit::Cli::Validator.errors_for(
+ described_class.relax(schema), schemas: ['urn:x'], id: '1'
+ )
+
+ expect(errors).to be_empty
+ end
+ end
+end
spec/scim/kit/cli_spec.rb
@@ -1,26 +1,6 @@
# 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
-
describe '.collection' do
it 'returns a bare array unchanged' do
expect(described_class.collection([{ id: '1' }])).to eql([{ id: '1' }])
.rubocop.yml
@@ -51,7 +51,6 @@ Lint/StructNewOverride:
Metrics/AbcSize:
Exclude:
- 'lib/scim/kit/v2/service_provider_configuration.rb'
- - 'lib/scim/kit/cli/app.rb'
Metrics/BlockLength:
Exclude:
@@ -63,10 +62,6 @@ Metrics/ClassLength:
Exclude:
- 'lib/scim/kit/cli/app.rb'
-Metrics/MethodLength:
- Exclude:
- - 'lib/scim/kit/cli/app.rb'
-
Metrics/ModuleLength:
Exclude:
- 'spec/**/*.rb'
@@ -97,10 +92,6 @@ 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
@@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
responses against a JSON Schema (built-in RFC 7643 schemas for
`discover`; derived from the target server's own `/Schemas` for
`list`/`get`).
+- Validate the attributes RFC 7643 section 3.1 requires of a returned
+ resource (`schemas`, `id`, and `meta.resourceType`), which were
+ previously accepted when absent.
+- Warn when a resource type declares a schema extension that the
+ server's `/Schemas` does not publish.
+
+### Fixed
+- Stop reporting valid responses as invalid: undeclared vendor
+ properties are now permitted, and `--attributes` relaxes required
+ checks so sparse responses pass.
+- Stop raising `KeyError` when a server's `/Schemas` omits an attribute
+ `type` or uses an unrecognized one.
+- Escape resource ids when building request URIs, which previously
+ raised `URI::InvalidURIError` for ids containing a space.
+- Resolve each resource type independently so a second `list`/`get` in
+ the same process no longer reuses the first lookup.
## [0.8.0] - 2026-03-31
### Changed
README.md
@@ -78,6 +78,50 @@ end
puts user_schema.to_json
```
+## CLI
+
+The `scim-kit` executable reads a remote SCIM server and can check its
+responses against JSON Schema.
+
+```bash
+scim-kit discover --url https://example.com/scim/v2
+scim-kit list User --filter 'userName eq "bjensen"' --count 10
+scim-kit get User 2819c223-7f76-453a-919d-413861904646
+```
+
+The base URL comes from `--url` or the `SCIM_KIT_URL` environment variable.
+Pass credentials with `--header`, which may be repeated:
+
+```bash
+export SCIM_KIT_URL=https://example.com/scim/v2
+scim-kit list User --header "Authorization: Bearer $TOKEN"
+```
+
+`list` also accepts `--start-index`, `--sort-by`, `--sort-order`, and
+`--attributes`; `get` accepts `--attributes`.
+
+### Validating a server
+
+`--validate` checks responses against JSON Schema and exits non-zero when a
+response does not conform. The body is always printed to stdout; validation
+errors go to stderr.
+
+```bash
+scim-kit discover --validate
+scim-kit list User --validate
+```
+
+`discover` validates `/ServiceProviderConfig`, `/Schemas`, and
+`/ResourceTypes` against the RFC 7643 schemas bundled with this gem. `list`
+and `get` build a schema from the target server's *own* `/Schemas` document,
+so they check that a server's resources match the schema it advertises.
+
+Validation enforces what RFC 7643 §3.1 requires of a returned resource — the
+`schemas` and `id` attributes, and `meta.resourceType` when `meta` is present
+— along with the types, canonical values, and required attributes the server
+declares. Undeclared vendor properties are permitted, and `--attributes`
+relaxes the required checks so sparse responses are not reported as errors.
+
## Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.