Paradocs: Extended Parametric gem + Documentation Generation
Declaratively define data schemas in your Ruby objects, and use them to whitelist, validate or transform inputs to your programs.
Useful for building self-documeting APIs, search or form objects. Or possibly as an alternative to Rails' strong parameters (it has no dependencies on Rails and can be used stand-alone).
gem 'paradocs', git: 'https://github.com/mtkachenk0/paradocs'
Define a schema
schema = Paradocs::Schema.new do
field(:title).type(:string).present
field(:status).options(["draft", "published"]).default("draft")
field(:tags).type(:array)
end
Populate and use. Missing keys return defaults, if provided.
form = schema.resolve(title: "A new blog post", tags: ["tech"])
form.output # => {title: "A new blog post", tags: ["tech"], status: "draft"}
form.errors # => {}
Undeclared keys are ignored.
form = schema.resolve(foobar: "BARFOO", title: "A new blog post", tags: ["tech"])
form.output # => {title: "A new blog post", tags: ["tech"], status: "draft"}
Validations are run and errors returned
form = schema.resolve({})
form.errors # => {"$.title" => ["is required"]}
If options are defined, it validates that value is in options
form = schema.resolve({title: "A new blog post", status: "foobar"})
form.errors # => {"$.status" => ["expected one of draft, published but got foobar"]}
A schema can have nested schemas, for example for defining complex forms.
person_schema = Paradocs::Schema.new do
field(:name).type(:string).required
field(:age).type(:integer)
field(:friends).type(:array).schema do
field(:name).type(:string).required
field(:email).policy(:email)
end
end
It works as expected
results = person_schema.resolve(
name: "Joe",
age: "38",
friends: [
{name: "Jane", email: "[email protected]"}
]
)
results.output # => {name: "Joe", age: 38, friends: [{name: "Jane", email: "[email protected]"}]}
Validation errors use JSON path expressions to describe errors in nested structures
results = person_schema.resolve(
name: "Joe",
age: "38",
friends: [
{email: "[email protected]"}
]
)
results.errors # => {"$.friends[0].name" => "is required"}
Type coercions (the type
method) and validations (the validate
method) are all policies.
Paradocs ships with a number of built-in policies.
Calls :to_s
on the value
field(:title).type(:string)
Calls :to_i
on the value
field(:age).type(:integer)
Calls :to_f
on the value
field(:price).type(:number)
Returns true
or false
(nil
is converted to false
).
field(:published).type(:boolean)
Attempts parsing value with Datetime.parse. If invalid, the error will be added to the output's errors
object.
field(:expires_on).type(:datetime)
Check value against custom regexp
field(:salutation).policy(:format, /^Mr\/s/)
# optional custom error message
field(:salutation).policy(:format, /^Mr\/s\./, "must start with Mr/s.")
field(:business_email).policy(:email)
Check that the key exists in the input.
field(:name).required
# same as
field(:name).policy(:required)
Note that required does not validate that the value is not empty. Use present for that.
Check that the key exists and the value is not blank.
field(:name).present
# same as
field(:name).policy(:present)
If the value is a String
, it validates that it's not blank. If an Array
, it checks that it's not empty. Otherwise it checks that the value is not nil
.
Check that a key exists in the input, or stop any further validations otherwise. This is useful when chained to other validations. For example:
field(:name).declared.present
The example above will check that the value is not empty, but only if the key exists. If the key doesn't exist no validations will run.
Validate that the value is greater than a number
field(:age).policy(:gt, 21)
Validate that the value is less than a number
field(:age).policy(:lt, 21)
Pass allowed values for a field
field(:status).options(["draft", "published"])
# Same as
field(:status).policy(:options, ["draft", "published"])
Specify value's length constraints
min:
- The attribute cannot have less than the specified length.max
- The attribute cannot have more than the specified length.eq
- The attribute should be exactly equal to the specified length.
field(:name).length(min: 5, max: 25)
field(:name).length(eq: 10)
Split comma-separated string values into an array. Useful for parsing comma-separated query-string parameters.
field(:status).policy(:split) # turns "pending,confirmed" into ["pending", "confirmed"]
You can also register your own custom policy objects. A policy can be not inherited from Paradocs::BasePolicy
, in this case it must implement the following methods: #valid?
, #coerce
, #message
, #meta_data
, #policy_name
class MyPolicy < Paradocs::BasePolicy
# Validation error message, if invalid
def message
'is invalid'
end
# Whether or not to validate and coerce this value
# if false, no other policies will be run on the field
def eligible?(value, key, payload)
true
end
# Transform the value
def coerce(value, key, context)
value
end
# Is the value valid?
def validate(value, key, payload)
true
end
# merge this object into the field's meta data
def meta_data
{type: :string}
end
end
You can register your policy with:
Paradocs.policy :my_policy, MyPolicy
And then refer to it by name when declaring your schema fields
field(:title).policy(:my_policy)
You can chain custom policies with other policies.
field(:title).required.policy(:my_policy)
Note that you can also register instances.
Paradocs.policy :my_policy, MyPolicy.new
For example, a policy that can be configured on a field-by-field basis:
class AddJobTitle
def initialize(job_title)
@job_title = job_title
end
def message
'is invalid'
end
# Noop
def eligible?(value, key, payload)
true
end
# Add job title to value
def coerce(value, key, context)
"#{value}, #{@job_title}"
end
# Noop
def validate(value, key, payload)
true
end
def meta_data
{}
end
end
# Register it
Paradocs.policy :job_title, AddJobTitle
Now you can reuse the same policy with different configuration
manager_schema = Paradocs::Schema.new do
field(:name).type(:string).policy(:job_title, "manager")
end
cto_schema = Paradocs::Schema.new do
field(:name).type(:string).policy(:job_title, "CTO")
end
manager_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, manager"}
cto_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, CTO"}
For simple policies that don't need all policy methods, you can:
Paradocs.policy :cto_job_title do
coerce do |value, key, context|
"#{value}, CTO"
end
end
# use it
cto_schema = Paradocs::Schema.new do
field(:name).type(:string).policy(:cto_job_title)
end
Paradocs.policy :over_21_and_under_25 do
coerce do |age, key, context|
age.to_i
end
validate do |age, key, context|
age > 21 && age < 25
end
end
The #clone
method returns a new instance of a schema with all field definitions copied over.
new_schema = original_schema.clone
New copies can be further manipulated without affecting the original.
# See below for #policy and #ignore
new_schema = original_schema.clone.policy(:declared).ignore(:id) do |sc|
field(:another_field).present
end
The #merge
method will merge field definitions in two schemas and produce a new schema instance.
basic_user_schema = Paradocs::Schema.new do
field(:name).type(:string).required
field(:age).type(:integer)
end
friends_schema = Paradocs::Schema.new do
field(:friends).type(:array).schema do
field(:name).required
field(:email).policy(:email)
end
end
user_with_friends_schema = basic_user_schema.merge(friends_schema)
results = user_with_friends_schema.resolve(input)
Fields defined in the merged schema will override fields with the same name in the original schema.
required_name_schema = Paradocs::Schema.new do
field(:name).required
field(:age)
end
optional_name_schema = Paradocs::Schema.new do
field(:name)
end
# This schema now has :name and :age fields.
# :name has been redefined to not be required.
new_schema = required_name_schema.merge(optional_name_schema)
The #meta
field method can be used to add custom meta data to field definitions.
These meta data can be used later when instrospecting schemas (ie. to generate documentation or error notices).
create_user_schema = Paradocs::Schema.new do
field(:name).required.type(:string).meta(label: "User's full name")
field(:status).options(["published", "unpublished"]).default("published")
field(:age).type(:integer).meta(label: "User's age")
field(:friends).type(:array).meta(label: "User friends").schema do
field(:name).type(:string).present.meta(label: "Friend full name")
field(:email).policy(:email).meta(label: "Friend's email")
end
end
Sometimes depending on the data the structure may vary. Most frequently used option is to use :declared
policy (a.k.a. conditional, but below is another option:
- Mutations are blocks that are assigned to a field and called during the validation (in #resolve), block receives all the related data and should return a subschema name.
- Subschemas are conditional schemas declared inside schemas. They doesn't exist until mutation block is called and decides to invoke a subschema.
person_schema = Paradocs::Schema.new do
field(:role).type(:string).options(["admin", "user"]).mutates_schema! do |value, key, payload, env|
value == :admin ? :admin_schema : :user_schema
end
subschema(:admin_schema) do
field(:permissions).present.type(:string).options(["superuser"])
field(:admin_field)
end
subschema(:user_schema) do
field(:permissions).present.type(:string).options(["readonly"])
field(:user_field)
end
end
results = person_schema.resolve(name: "John", age: 20, role: :admin, permissions: "superuser")
results.output # => {name: "John", age: 20, role: :admin, permissions: "superuser", admin_field: nil}
results = person_schema.resolve(name: "John", age: 20, role: :admin, permissions: "readonly")
results.errors => {"$.permissions"=>["must be one of superuser, but got readonly"]}
You can optionally use an existing schema instance as a nested schema:
friends_schema = Paradocs::Schema.new do
field(:friends).type(:array).schema do
field(:name).type(:string).required
field(:email).policy(:email)
end
end
person_schema = Paradocs::Schema.new do
field(:name).type(:string).required
field(:age).type(:integer)
# Nest friends_schema
field(:friends).type(:array).schema(friends_schema)
end
A Schema
instance has a #structure
method that allows instrospecting schema meta data.
create_user_schema.structure[:name][:label] # => "User's full name"
create_user_schema.structure[:age][:label] # => "User's age"
create_user_schema.structure[:friends][:label] # => "User friends"
# Recursive schema structures
create_user_schema.structure => {
_errors: [],
_subschemes: {},
name: {
required: true,
type: :string,
label: "User's full name"
},
status: {
options: ["published", "unpublished"],
default: "published"
},
age: {
type: :integer,
label: "User's age"
},
friends: {
type: :array,
label: "User friends",
structure: {
_subschemes: {},
name: {
type: :string,
required: true,
present: true,
label: "Friend full name"
},
email: {label: "Friend's email"}
}
}
}
Note that many field policies add field meta data.
create_user_schema.structure[:name][:type] # => :string
create_user_schema.structure[:name][:required] # => true
create_user_schema.structure[:status][:options] # => ["published", "unpublished"]
create_user_schema.structure[:status][:default] # => "published"
A Schema
instance also has a #flatten_structure
method that allows instrospecting schema meta data without deep nesting.
{
_errors: [],
_subschemes: {},
"name"=>{
required: true,
type: :string,
label: "User's full name",
json_path: "$.name"
},
"status"=>{
options: ["published", "unpublished"],
default: "published",
json_path: "$.status"
},
"age"=>{
type: :integer,
label: "User's age", :json_path=>"$.age"
},
"friends"=>{
type: :array,
label: "User friends",
json_path: "$.friends"
},
"friends.name"=>{
type: :string,
required: true,
present: true,
label: "Friend full name",
json_path: "$.friends.name"
},
"friends.email"=>{
label: "Friend's email",
json_path: "$.friends.email"
}
}
The #walk
method can recursively walk a schema definition and extract meta data or field attributes.
schema_documentation = create_user_schema.walk do |field|
{type: field.meta_data[:type], label: field.meta_data[:label]}
end.output
# Returns
{
name: {type: :string, label: "User's full name"},
age: {type: :integer, label: "User's age"},
status: {type: :string, label: nil},
friends: [
{
name: {type: :string, label: "Friend full name"},
email: {type: nil, label: "Friend email"}
}
]
}
When passed a symbol, it will collect that key from field meta data.
schema_labels = create_user_schema.walk(:label).output
# returns
{
name: "User's full name",
age: "User's age",
status: nil,
friends: [
{name: "Friend full name", email: "Friend email"}
]
}
Potential uses for this are generating documentation (HTML, or JSON Schema, Swagger, or maybe even mock API endpoints with example data.
You can use schemas and fields on their own, or include the DSL
module in your own classes to define form objects.
require "parametric/dsl"
class CreateUserForm
include Paradocs::DSL
schema(:test) do
field(:name).type(:string).required
field(:email).policy(:email).required
field(:age).type(:integer)
subschema_by(:age) { |age| age > 18 ? :allow : :deny }
end
subschema_for(:test, name: :allow) { field(:role).options(["sign_in"]) }
subschema_for(:test, name: :deny) { field(:role).options([]) }
attr_reader :params, :errors
def initialize(input_data)
results = self.class.schema.resolve(input_data)
@params = results.output
@errors = results.errors
end
def run!
if !valid?
raise InvalidFormError.new(errors)
end
run
end
def valid?
!errors.any?
end
private
def run
User.create!(params)
end
end
Form schemas can also be defined by passing another form or schema instance. This can be useful when building form classes in runtime.
UserSchema = Paradocs::Schema.new do
field(:name).type(:string).present
field(:age).type(:integer)
end
class CreateUserForm
include Paradocs::DSL
# copy from UserSchema
schema UserSchema
end
Sub classes of classes using the DSL will inherit schemas defined on the parent class.
class UpdateUserForm < CreateUserForm
# All field definitions in the parent are conserved.
# New fields can be defined
# or existing fields overriden
schema do
# make this field optional
field(:name).declared.present
end
def initialize(user, input_data)
super input_data
@user = user
end
private
def run
@user.update params
end
end
Sometimes it's useful to apply the same policy to all fields in a schema.
For example, fields that are required when creating a record might be optional when updating the same record (ie. PATCH operations in APIs).
class UpdateUserForm < CreateUserForm
schema.policy(:declared)
end
This will prefix the :declared
policy to all fields inherited from the parent class.
This means that only fields whose keys are present in the input will be validated.
Schemas with default policies can still define or re-define fields.
class UpdateUserForm < CreateUserForm
schema.policy(:declared) do
# Validation will only run if key exists
field(:age).type(:integer).present
end
end
Sometimes you'll want a child class to inherit most fields from the parent, but ignoring some.
class CreateUserForm
include Paradocs::DSL
schema do
field(:uuid).present
field(:status).required.options(["inactive", "active"])
field(:name)
end
end
The child class can use ignore(*fields)
to ignore fields defined in the parent.
class UpdateUserForm < CreateUserForm
schema.ignore(:uuid, :status) do
# optionally add new fields here
end
end
Another way of modifying inherited schemas is by passing options.
class CreateUserForm
include Paradocs::DSL
schema(default_policy: :noop) do |opts|
field(:name).policy(opts[:default_policy]).type(:string).required
field(:email).policy(opts[:default_policy).policy(:email).required
field(:age).type(:integer)
end
# etc
end
The :noop
policy does nothing. The sub-class can pass its own default_policy.
class UpdateUserForm < CreateUserForm
# this will only run validations keys existing in the input
schema(default_policy: :declared)
end
You can use a combination of #clone
and #policy
to change schema-wide field policies on the fly.
For example, you might have a form object that supports creating a new user and defining mandatory fields.
class CreateUserForm
include Paradocs::DSL
schema do
field(:name).present
field(:age).present
end
attr_reader :errors, :params
def initialize(payload: {})
results = self.class.schema.resolve(payload)
@errors = results.errors
@params = results.output
end
def run!
User.create(params)
end
end
Now you might want to use the same form object to update and existing user supporting partial updates.
In this case, however, attributes should only be validated if the attributes exist in the payload. We need to apply the :declared
policy to all schema fields, only if a user exists.
We can do this by producing a clone of the class-level schema and applying any necessary policies on the fly.
class CreateUserForm
include Paradocs::DSL
schema do
field(:name).present
field(:age).present
end
attr_reader :errors, :params
def initialize(payload: {}, user: nil)
@payload = payload
@user = user
# pick a policy based on user
policy = user ? :declared : :noop
# clone original schema and apply policy
schema = self.class.schema.clone.policy(policy)
# resolve params
results = schema.resolve(params)
@errors = results.errors
@params = results.output
end
def run!
if @user
@user.update_attributes(params)
else
User.create(params)
end
end
end
Form objects can optionally define more than one schema by giving them names:
class UpdateUserForm
include Paradocs::DSL
# a schema named :query
# for example for query parameters
schema(:query) do
field(:user_id).type(:integer).present
end
# a schema for PUT body parameters
schema(:payload) do
field(:name).present
field(:age).present
end
end
Named schemas are inherited and can be extended and given options in the same way as the nameless version.
Named schemas can be retrieved by name, ie. UpdateUserForm.schema(:query)
.
If no name given, .schema
uses :schema
as default schema name.
Sometimes you don't know the exact field names but you want to allow arbitrary fields depending on a given pattern.
# with this payload:
# {
# title: "A title",
# :"custom_attr_Color" => "red",
# :"custom_attr_Material" => "leather"
# }
schema = Paradocs::Schema.new do
field(:title).type(:string).present
# here we allow any field starting with /^custom_attr/
# this yields a MatchData object to the block
# where you can define a Field and validations on the fly
# https://ruby-doc.org/core-2.2.0/MatchData.html
expand(/^custom_attr_(.+)/) do |match|
field(match[1]).type(:string).present
end
end
results = schema.resolve({
title: "A title",
:"custom_attr_Color" => "red",
:"custom_attr_Material" => "leather",
:"custom_attr_Weight" => "",
})
results.ouput[:Color] # => "red"
results.ouput[:Material] # => "leather"
results.errors["$.Weight"] # => ["is required and value must be present"]
NOTES: dynamically expanded field names are not included in Schema#structure
metadata, and they are only processes if fields with the given expressions are present in the payload. This means that validations applied to those fields only run if keys are present in the first place.
Structs turn schema definitions into objects graphs with attribute readers.
Add optional Paradocs::Struct
module to define struct-like objects with schema definitions.
require 'parametric/struct'
class User
include Paradocs::Struct
schema do
field(:name).type(:string).present
field(:friends).type(:array).schema do
field(:name).type(:string).present
field(:age).type(:integer)
end
end
end
User
objects can be instantiated with hash data, which will be coerced and validated as per the schema definition.
user = User.new(
name: 'Joe',
friends: [
{name: 'Jane', age: 40},
{name: 'John', age: 30},
]
)
# properties
user.name # => 'Joe'
user.friends.first.name # => 'Jane'
user.friends.last.age # => 30
Both the top-level and nested instances contain error information:
user = User.new(
name: '', # invalid
friends: [
# friend name also invalid
{name: '', age: 40},
]
)
user.valid? # false
user.errors['$.name'] # => "is required and must be present"
user.errors['$.friends[0].name'] # => "is required and must be present"
# also access error in nested instances directly
user.friends.first.valid? # false
user.friends.first.errors['$.name'] # "is required and must be valid"
Instantiating structs with .new!(hash)
will raise a Paradocs::InvalidStructError
exception if the data is validations fail. It will return the struct instance otherwise.
Paradocs::InvalidStructError
includes an #errors
property to inspect the errors raised.
begin
user = User.new!(name: '')
rescue Paradocs::InvalidStructError => e
e.errors['$.name'] # "is required and must be present"
end
You can also pass separate struct classes in a nested schema definition.
class Friend
include Paradocs::Struct
schema do
field(:name).type(:string).present
field(:age).type(:integer)
end
end
class User
include Paradocs::Struct
schema do
field(:name).type(:string).present
# here we use the Friend class
field(:friends).type(:array).schema Friend
end
end
Struct subclasses can add to inherited schemas, or override fields defined in the parent.
class AdminUser < User
# inherits User schema, and can add stuff to its own schema
schema do
field(:permissions).type(:array)
end
end
Struct#to_h
returns the ouput hash, with values coerced and any defaults populated.
class User
include Paradocs::Struct
schema do
field(:name).type(:string)
field(:age).type(:integer).default(30)
end
end
user = User.new(name: "Joe")
user.to_h # {name: "Joe", age: 30}
Paradocs::Struct
implements #==()
to compare two structs Hash representation (same as struct1.to_h.eql?(struct2.to_h)
.
Users can override #==()
in their own classes to do whatever they need.