Media Types based on scheme, with versioning, views, suffixes and validations.
This library makes it easy to define schemas that can be used to validate JSON objects based on their Content-Type.
Add this line to your application's Gemfile:
gem 'media_types'
And then execute:
$ bundle
Or install it yourself as:
$ gem install media_types
Define a validation:
require 'media_types'
module Acme
MediaTypes::set_organisation Acme, 'acme'
class FooValidator
include MediaTypes::Dsl
use_name 'foo'
validations do
attribute :foo, String
end
end
end
Validate an object:
Acme::FooValidator.validate!({ foo: 'bar' })
require 'media_types'
class Venue
include MediaTypes::Dsl
def self.organisation
'mydomain'
end
use_name 'venue'
validations do
version 2 do
attribute :name, String
collection :location do
attribute :latitude, Numeric
attribute :longitude, Numeric
attribute :altitude, AllowNil(Numeric)
end
link :self
link :route, allow_nil: true
end
version 1 do
attribute :name, String
attribute :coords, String, optional: :loose
attribute :updated_at, String
link :self
end
view 'create' do
collection :location do
attribute :latitude, Numeric
attribute :longitude, Numeric
attribute :altitude, AllowNil(Numeric)
end
versions [1, 2] do |v|
collection :location do
link :extra if v > 1
attribute :latitude, Numeric
attribute :longitude, Numeric
attribute :altitude, AllowNil(Numeric)
end
end
end
end
end
If you include 'MediaTypes::Dsl' in your class you can use the following functions within a validation do
block to define your schema:
Adds an attribute to the schema, if a +block+ is given, uses that to test against instead of +type+
param | type | description |
---|---|---|
key | Symbol |
the attribute name |
opts | Hash |
options to pass to Scheme or Attribute |
type | Class , === , Scheme |
The type of the value can be anything that responds to === , or scheme to use if no &block is given. Defaults to Object without a &block and to Hash with a &block . |
optional: | TrueClass , FalseClass |
if true, key may be absent, defaults to false |
&block | Block |
defines the scheme of the value of this attribute |
require 'media_types'
class MyMedia
include MediaTypes::Dsl
validations do
attribute :foo, String
end
end
MyMedia.valid?({ foo: 'my-string' })
# => true
class MyMedia
include MediaTypes::Dsl
validations do
attribute :foo do
attribute :bar, String
end
end
end
MyMedia.valid?({ foo: { bar: 'my-string' }})
# => true
Allow for any key. The &block
defines the Schema for each value.
param | type | description |
---|---|---|
scheme | Scheme , NilClass |
scheme to use if no &block is given |
allow_empty: | TrueClass , FalsClass |
if true, empty (no key/value present) is allowed |
expected_type: | Class , |
forces the validated value to have this type, defaults to Hash . Use Object if either Hash or Array is fine |
&block | Block |
defines the scheme of the value of this attribute |
class MyMedia
include MediaTypes::Dsl
validations do
collection :foo do
any do
attribute :bar, String
end
end
end
end
MyMedia.valid?({ foo: [{ anything: { bar: 'my-string' }, other_thing: { bar: 'other-string' } }] })
# => true
Allow for extra keys in the schema/collection even when passing strict: true
to #validate!
class MyMedia
include MediaTypes::Dsl
validations do
collection :foo do
attribute :required, String
not_strict
end
end
end
MyMedia.valid?({ foo: [{ required: 'test', bar: 42 }] })
# => true
Expect a collection such as an array or hash. The &block
defines the Schema for each item in that collection.
param | type | description |
---|---|---|
key | Symbol |
key of the collection (same as #attribute ) |
scheme | Scheme , NilClass , Class |
scheme to use if no &block is given or Class of each item in the collection |
allow_empty: | TrueClass , FalseClass |
if true, empty (no key/value present) is allowed |
expected_type: | Class , |
forces the validated value to have this type, defaults to Array . Use Object if either Array or Hash is fine. |
optional: | TrueClass , FalseClass |
if true, key may be absent, defaults to false |
&block | Block |
defines the scheme of the value of this attribute |
class MyMedia
include MediaTypes::Dsl
validations do
collection :foo, String
end
end
MyMedia.valid?({ collection: ['foo', 'bar'] })
# => true
class MyMedia
include MediaTypes::Dsl
validations do
collection :foo do
attribute :required, String
attribute :number, Numeric
end
end
end
MyMedia.valid?({ foo: [{ required: 'test', number: 42 }, { required: 'other', number: 0 }] })
# => true
Expect a link with a required href: String
attribute
param | type | description |
---|---|---|
key | Symbol |
key of the link (same as #attribute ) |
allow_nil: | TrueClass , FalseClass |
if true, value may be nil |
optional: | TrueClass , FalseClass |
if true, key may be absent, defaults to false |
&block | Block |
defines the scheme of the value of this attribute, in addition to the href attribute |
class MyMedia
include MediaTypes::Dsl
validations do
link :self
link :image
end
end
MyMedia.valid?({ _links: { self: { href: 'https://example.org/s' }, image: { href: 'https://image.org/i' }} })
# => true
class MyMedia
include MediaTypes::Dsl
validations do
link :image do
attribute :templated, TrueClass
end
end
end
MyMedia.valid?({ _links: { image: { href: 'https://image.org/{md5}', templated: true }} })
# => true
If your type has a validations, you can now use this media type for validation:
Venue.valid?({
#...
})
# => true if valid, false otherwise
Venue.validate!({
# /*...*/
})
# => raises if it's not valid
If an array is passed, check the scheme for each value, unless the scheme is defined as expecting a hash:
expected_hash = Scheme.new(expected_type: Hash) { attribute(:foo) }
expected_object = Scheme.new { attribute(:foo) }
expected_hash.valid?({ foo: 'string' })
# => true
expected_hash.valid?([{ foo: 'string' }])
# => false
expected_object.valid?({ foo: 'string' })
# => true
expected_object.valid?([{ foo: 'string' }])
# => true
Any media type object can be converted in valid string to be used with Content-Type
or Accept
:
Venue.mime_type.identifier
# => "application/vnd.mydomain.venue.v2+json"
Venue.mime_type.version(1).identifier
# => "application/vnd.mydomain.venue.v1+json"
Venue.mime_type.to_s(0.2)
# => "application/vnd.mydomain.venue.v2+json; q=0.2"
Venue.mime_type.collection.identifier
# => "application/vnd.mydomain.venue.v2.collection+json"
Venue.mime_type.view('active').identifier
# => "application/vnd.mydomain.venue.v2.active+json"
A defined schema has the following functions available:
Example: Venue.valid?({ foo: 'bar' })
Allows passing in validation options as a second parameter.
Example: Venue.validate!({ foo: 'bar' })
Allows passing in validation options as a second parameter.
Example: Venue.version(42).validatable?
Tests whether the current configuration of the schema has a validation defined.
Example: Venue.register
Registers the media type to the registry.
Example: Venue.view('create')
Returns a schema validator configured with the specified view.
Example: Venue.version(42)
Returns a schema validator configured with the specified version.
Example: Venue.suffix(:json)
Returns a schema validator configured with the specified suffix.
Example: Venue.version(2).identifier
(returns 'application/vnd.application.venue.v2'
)
Returns the IANA compatible Media Type Identifier for the configured schema.
Example: Venue.available_validations
Returns a list of all the schemas that are defined.
If the MediaTypes you create enforce a specification you do not expect them to, it will cause problems that will be very difficult to fix, as other code, which utilises your MediaType, would break when you change the specification. This is because the faulty MediaType definition will start to make other code dependent on the specification it defines. For example, consider what would happen if you release a MediaType which defines an attribute foo
to be a String
, and run a server which defines such a specification. Later, you realise you actually wanted foo
to be Numeric
. What can you do?
Well, during this time, other people started to write code which conformed to the specification defined by the faulty MediaType. So, it's going to be extremely difficult to revert your mistake. For this reason, it is vital that, when using this library, your MediaTypes define the correct specification.
To this end, we provide you with a few avenues to check whether MediaTypes define the specifications you actually intend by checking examples of JSON you expect to be compliant/non-compliant with the MediaType definitions you write out.
These are as follows:
- The library provides two methods (
assert_pass
andassert_fail
), which allow specifying JSON fixtures that are compliant (assert_pass
) or non-compliant (assert_fail
). - The library provides a way to validate those fixtures against the MediaType specification with the
assert_mediatype
method. - The library automatically performs a MediaType's checks defined by (1) the first time an object is validated against the MediaType, and throws an error if any of the checks fail.
- The library provides a way to run the checks carried out by (3) on load, using the method
assert_sane!
so that an application will not run if any of the MediaType's checks don't pass.
These four options are examined in detail below.
The library provides the assert_mediatype
method, which allows running the checks for a particular MediaType
within Minitest with assert_pass
and assert_fail
.
If you are using Minitest, you can make assert_mediatype
available by calling include MediaTypes::Testing::Assertions
in the test class (e.g. Minitest::Runnable
):
module Minitest
class Test < Minitest::Runnable
include MediaTypes::Testing::Assertions
end
end
The example below demonstrates how to use assert_pass
and assert_fail
within a MediaType, and how to use the assert_mediatype
method in MiniTest tests to validate them.
class MyMedia
include MediaTypes::Dsl
def self.organisation
'acme'
end
use_name 'test'
validations do
# Using "any Numeric" this MediaType doesn't care what key names you use.
# However, it does care that those keys point to a Numeric value.
any Numeric
assert_pass '{"foo": 42}'
assert_pass <<-FIXTURE
{ "foo": 42, "bar": 43 }
FIXTURE
# The keyword "any" means there are no required keys, so having no keys should also pass.
assert_pass '{}'
# This MediaType should not accept anything other then a Numeric value.
assert_fail <<-FIXTURE
{ "foo": { "bar": "string" } }
FIXTURE
assert_fail '{"foo": {}}'
assert_fail '{"foo": null}', loose: true
assert_fail '{"foo": [42]}', loose: false
end
end
class MyMediaTest < Minitest::Test
include MediaTypes::Testing::Assertions
def test_mediatype_specification
assert_mediatype MyMedia
end
end
class MyMediaTest < Minitest::Test
include MediaTypes::Testing::Assertions
def test_mediatype_specification
assert_mediatype MyMedia
end
end
If you are using another testing framework, you will not be able to use the assert_mediatype
method. Instead, you can test your MediaTypes by using the assert_sane!
method (documented below) and rescuing the errors it will throw when it fails. The snippet below shows an example adaptation for MiniTest, which you can use as a guide.
def test_mediatype(mediatype)
mediatype.assert_sane!
assert mediatype.media_type_validations.scheme.asserted_sane?
rescue MediaTypes::AssertionError => e
flunk e.message
end
end
The assert_pass
and assert_fail
methods take a JSON string (as shown below). The first time the validate!
method is called on a MediaType, the assertions for that media type are run.
This is done as a last line of defence against introducing faulty MediaTypes into your software. Ideally, you want to carry out these checks on load rather than on a running application. This functionality is provided by the assert_sane!
method, which can be called on a particular MediaType:
MyMedia.assert_sane!
# true
The fixtures provided to the assert_pass
and assert_fail
methods are evaluated within the context of the block they are placed in. It's therefore possible to write a test for a (complex) optional attribute, without that test cluttering the fixtures for the entire mediatype.
class MyMedia
include MediaTypes::Dsl
expect_string_keys
def self.organisation
'acme'
end
use_name 'test'
validations do
attribute :foo, Hash, optional: true do
attribute :bar, Numeric
# This passes, since in this context the "bar" key is required to have a Numeric value.
assert_pass '{"bar": 42}'
end
attribute :rep, Numeric
# This passes, since the attribute "foo" is optional.
assert_pass '{"rep": 42}'
end
end
When interacting with Ruby objects defined by your MediaType, you want to avoid getting nil
values, just because the the wrong key type is being used (e.g. obj['foo']
instead of obj[:foo]
).
To this end, the library provides the ability to specify the expected type of keys in a MediaType; by default symbol keys are expected.
Key type expectations can be set at the module level. Each MediaType within this module will inherit the expectation set by that module.
module Acme
MediaTypes.expect_string_keys(self)
# The MyMedia class expects string keys, as inherited from the Acme module.
class MyMedia
include MediaTypes::Dsl
def self.organisation
'acme'
end
use_name 'test'
validations do
any Numeric
end
end
end
If you validate an object with a different key type than expected, an error will be thrown:
Acme::MyMedia.validate! { "something": 42 }
# => passes, because all keys are a string
Acme::MyMedia.validate! { something: 42 }
# => throws a ValidationError , because 'something' is a symbol key
A key type expectation set by a Module can be overridden by calling either expect_symbol_keys
or expect_string_keys
inside the MediaType class.
module Acme
MediaTypes.expect_string_keys(self)
class MyOverridingMedia
include MediaTypes::Dsl
def self.organisation
'acme'
end
use_name 'test'
# Expect keys to be symbols
expect_symbol_keys
validations do
any Numeric
end
end
end
Now the MediaType throws an error when string keys are used.
Acme::MyOverridingMedia.validate! { something: 42 }
# => passes, because all keys are a symbol
Acme::MyOverridingMedia.validate! { "something": 42 }
# => throws a ValidationError , because 'something' is a string key
If you parse JSON with the wrong key type, as shown below, the resultant object will fail the validations.
class MyMedia
include MediaTypes::Dsl
def self.organisation
'acme'
end
use_name 'test'
# Expect keys to be symbols
expect_symbol_keys
validations do
any Numeric
end
end
json = JSON.parse('{"foo": {}}', { symbolize_names: false })
# If MyMedia expects symbol keys
MyMedia.valid?(json)
# Returns false
MediaTypes::Serialization
: 🌀 Add media types supported serialization to Rails.
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, call bundle exec rake release
to create a new git tag, push git commits and tags, and
push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at SleeplessByte/media-types-ruby