Gem Version Coverage Status

Katachi

A tool for describing and validating objects as intuitively as possible.

Katachi.compare(
    value: {name: 'John', age: 30},
    shape: {name: String, age: Integer}
).match? # => true

What's with the name?

The word “katachi” is a composite of “kata” (pattern) and “chi” (magical power), thus it includes meanings such as “complete form” or “form telling an attractive story.” It can reveal the relationship between shape, function and meaning.

https://symmetry-us.com/about_the_site/what-is-katachi/

This tool is all about defining the shape of your data. The usual words of schema, definition, or validator all felt too formal. Since Ruby originated in Japan, I looked up the Japanese word for shape. It came back as 形 (katachi), and the above quote was the first thing I saw when checking for prior usage. It felt like a perfect fit.

Features

Basic Shape Matching

A comparison system built on the power of the Ruby === operator.

Kt = Katachi
Kt.compare(value: 'hello', shape: 'hello').match? # => true
Kt.compare(value: 'hello', shape: 'world').match? # => false
Kt.compare(value: 'hello', shape: String).match? # => true
Kt.compare(value: 'hello', shape: /ell/).match? # => true
Kt.compare(value: 4, shape: 1..10).match? # => true
Kt.compare(value: 4, shape: ->(v) { v > 3 }).match? # => true

If you're dealing with more variable data, there'sany_of to allow multiple types. This is especially useful for optional values, since we treat nil just like any other value.

value = user.preferred_name
shape = Kt.any_of(String, nil)
Kt.compare(value:, shape:).match? # => true

An Easy-To-Use Shape Library

We provide some common shapes that can be accessed by :${name}.

Kt.compare(
    value: "123e4567-e89b-12d3-a456-426614174000",
    shape: :$uuid
).match? # => true

You can also add your own shapes to fit your needs.

Kt.add_shape(:$even, ->(v) { v.even? })
Kt.compare(value: 4, shape: :$even).match? # => true

The full list of included shapes can be found in the predefined_shapes.rb file. If you think there's a shape everyone should have, feel free to open an issue! Or better yet, a PR!

Array Comparison

Arrays are checked to ensure their contents also match the shape.

Kt.compare(value: [1], shape: [Integer]).match? # => true

Since arrays aren't usually a fixed length, we don't compare the length of the value and shape arrays. Instead, we treat the contents of the shape array like any_of. [String, Integer] is effectively shorthand for [Kt.any_of(String, Integer)].

# pseudo-code for how arrays are compared
array_matches = value.all? do |element|
  shape.any? do |shape_element|
    Kt.compare(value: element, shape: shape_element).match?
  end
end

Seeing a few examples is probably the best way to understand how this works.

Kt.compare(value: [1, 2, 3, 4, 5], shape: [Integer]).match? # => true
Kt.compare(value: ['a', 'b', 'c'], shape: [Integer]).match? # => false
Kt.compare(value: [1, 2, 'c'], shape: [Integer]).match? # => false
Kt.compare(value: ['a', 2, 'c', 4], shape: [Integer, String]).match? # => true

We said arrays aren't usually a fixed length but it does happen.

For this situation, the Ruby in operator is your friend.

Here's how you can check for an array of exactly 5 elements without a lot of typing.

value = [1, 2, 3, 4, 5]
shape = ->(v) { v in ([Integer] * 5) }
Kt.compare(value:, shape:).match? # => true

It also works for when you want to check for specific values at specific indexes.

value = [1, 'a', 2]
shape = ->(v) { v in [Integer, String, Integer] }
Kt.compare(value:, shape:).match? # => true

Checks are recursive, so you can nest arrays as deep as you like.

value = [1, [2, [3, 4]]]
shape = [Integer, [Integer, [Integer]]]
Kt.compare(value:, shape:).match? # => true

Hash Comparison

Hashes are checked to ensure their keys and values match the shape.

value = {a: 1}
shape = {a: Integer}
Kt.compare(value:, shape:).match? # => true

By default, no extra or missing hash keys are allowed.

# This will fail because `:b` is not in the shape
value = {a: 1, b: 2}
shape = {a: Integer}
Kt.compare(value:, shape:).match? # => false

# This will fail because `:b` is missing from the value
value = {a: 1}
shape = {a: Integer, b: String}
Kt.compare(value:, shape:).match? # => false

If you want to allow extra keys, no special syntax is needed. Ruby comes to the rescue! Ruby accepts more than just strings and symbols as hash keys. We take advantage of this by applying the same comparison logic to the keys as we do to the values.

value = {a: 1, b: 2, c: 3}
shape = {a: Integer, Symbol => Integer}
Kt.compare(value:, shape:).match? # => true

This means you can use any shape you like for the keys, though it's usually best to stick to simple shapes.

value = { "123e4567-e89b-12d3-a456-426614174000" => "My Id" }
shape = { :$uuid => String}
Kt.compare(value:, shape:).match? # => true

We've made sure that if you go through the trouble of describing an exact key, it will override more generic matches. We consider an exact key to be one that doesn't contain a Class, a Range, a Proc, or a Regexp.

value = {a: 'a', b: 'b', c: 'c'}
shape = {a: 'foo', Symbol => String}
Kt.compare(value:, shape:).match? # => false

For making keys optional, we provide a special :$undefined shape.

value = {a: 1}
shape = {a: Integer, b: Kt.any_of(Integer, :$undefined)}
Kt.compare(value:, shape:).match? # => true

As with arrays, hashes can be nested as deep as you like.

value = {a: {b: {c: 1}}}
shape = {a: {b: {c: Integer}}}
Kt.compare(value:, shape:).match? # => true

Custom Comparisons

Need something more complex? Just add a kt_compare class method to whatever you'd like to compare. As long as it returns a Katachi::Result, you're good to go!

class CanRideThisRollerCoaster
  def self.kt_compare(value:)
    age_check = Kt.compare(value: value.age, shape: 14..)
    height_check = Kt.compare(value: value.height, shape: 42..123)
    has_parent_check = Kt.compare(value: value.has_parent, shape: true)
    is_allowed = height_check.match? && (age_check.match? || has_parent_check.match?)
    Kt::Result.new(
      value:,
      shape: self,
      code: is_allowed ? :match : :mismatch,
      child_results: {age_check:, height_check:, has_parent_check:}
    )
  end
end

RSpec Integration

When using Rspec, the way it turns question mark methods in to be_ methods is a perfect fit for our match? method.

# The following two lines are equivalent
expect(Kt.compare('abc', 'abc').match?).to be true
expect(Kt.compare('abc', 'abc')).to be_match

For when you don't want a match, RSpec has a helpful utility for defining the opposite of a matcher.

RSpec::Matchers.define_negated_matcher :be_mismatch, :be_match
expect(Kt.compare('abc', 123)).to be_mismatch

We've also added RSpec matchers to make testing your shapes even easier.

require 'katachi/rspec'

expect(Kt.compare('abc', 123)).to have_compare_code(:mismatch)
expect('abc').to have_shape(String)
expect('abc').to have_shape('abc').with_code(:exact_match)

Minitest Integration

We provide both custom assertions and expectations for Minitest.

require 'katachi/minitest'

shape = [1, 2, 3]
assert_shape(shape, [1, 2, 3])
refute_shape(shape, [1, 2, 4])

_([1, 2, 3]).must_match_shape(shape)
_([1, 2, 4]).wont_match_shape(shape)

Detailed Diagnostics

All comparisons return a Katachi::Result object that contains detailed information about the comparison.

value = {a: 1, foo: :bar}
shape = { a: Integer, foo: String }
result = Kt.compare(value:, shape:)
result.match? # => false
result.code # => :hash_is_mismatch
result.child_results # contains the recursive results of interior comparisons
result.to_s == ":hash_is_mismatch <-- compare(value: {a: 1, foo: :bar}, shape: {a: Integer, foo: String})\n  :hash_has_no_missing_keys <-- compare(value: {a: 1, foo: :bar}, shape: {a: Integer, foo: String}); child_label: :$required_keys\n    :hash_key_exact_match <-- compare(value: :a, shape: :a); child_label: :a\n    :hash_key_exact_match <-- compare(value: :foo, shape: :foo); child_label: :foo\n  :hash_has_no_extra_keys <-- compare(value: {a: 1, foo: :bar}, shape: {a: Integer, foo: String}); child_label: :$extra_keys\n    :hash_key_exactly_allowed <-- compare(value: :a, shape: :a); child_label: :a\n    :hash_key_exactly_allowed <-- compare(value: :foo, shape: :foo); child_label: :foo\n  :hash_values_are_mismatch <-- compare(value: {a: 1, foo: :bar}, shape: {a: Integer, foo: String}); child_label: :$values\n    :kv_specific_match <-- compare(value: {a: 1}, shape: {a: Integer}); child_label: [:a, 1]\n      :match <-- compare(value: 1, shape: Integer); child_label: Integer\n    :kv_specific_mismatch <-- compare(value: {foo: :bar}, shape: {foo: String}); child_label: [:foo, :bar]\n      :mismatch <-- compare(value: :bar, shape: String); child_label: String\n".chomp

Future Features Under Consideration

  • [ ] More shapes (e.g. :$email, :$url, :$iso_8601)
  • [ ] More "matching modifiers" (e.g. all_of, one_of, none_of)
  • [ ] Docusaurus github pages for documentation
  • [ ] More output formats (e.g. to_json, to_hash, etc...)
  • [ ] Custom shape codes (e.g. :email_is_invalid)
  • [ ] Rails integration (e.g. validates_shape_of)
  • [ ] Shape-to-TypeScript conversion
  • [ ] Shape-to-Zod conversion
  • [ ] Shape-to-OpenAPI conversion
  • [ ] katachi-rspec-api for testing+documenting APIs in a way inspired by RSwag

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add katachi

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install katachi

Inspiration

This is inspired by my experiences testing using RSwag and from my small part in helping maintain it. I wasn't happy with how often I had to look up the OpenAPI spec to be able to follow it.

A lot of this came down to OpenAPI itself being complex and making significant changes over the years (e.g. x-nullable: true → nullable: true → type: ["string", "null"]). A bigger part is they're limited to valid JSON, so they have very few tools to work with.

I started wondering if I could tweak RSwag to smooth over some of these rough edges. Is there a way to make it easier to write and harder to mess up?

It started as consolidating a few helper functions together, before a bigger question hit me:

“What if I ditched writing OpenAPI entirely?”

Rather than drag all of their maintainers and users along with my crackpot schemes, I decided it was time to set off on a new project: Katachi

Development and Contributing

See CONTRIBUTING.md for information on how to contribute to Katachi.

License

The gem is available as open source under the terms of the MIT License.