Bluepine
Bluepine
is a DSL for defining API Schema/Endpoint with the capabilities to generate Open API (v3)
spec (other specs is coming soon), validate API request and serialize object for API response based on single schema definition.
Table of Contents
- Quick Start
- Installation
- Attributes
- Resolver
- Serialization
- Endpoint
- Validation
- Generating API Specifications
Quick Start
Defining a schema
Let's start by creating a simple schema. (For a complete list of attributes and its options please see Attributes section)
A schema can be created and registered separately, or we can use
Resolver
to create and register in one step.
require "bluepine"
# Schema is just an `ObjectAttribute`
Bluepine::Resolver.new do
# Defines :hero schema
schema :hero do
string :name, min: 4
# recursive schema
array :friends, of: :hero
# nested object
object :stats do
number :strength, default: 0
end
# reference
schema :team
end
# Defines :team schema
schema :team do
string :name, default: "Avengers"
end
end
Serializing schema
In order to serialize schema, we just pass schema defined in previous step to Serializer
.
The object to be serialized can be a
Hash
or anyObject
with method/accessor.
hero = {
name: "Thor",
friends: [
{
name: "Iron Man",
stats: {
strength: "9"
}
}
],
stats: {
strength: "8"
}
}
# or using our own Model class
hero = Hero.new(name: "Thor")
serializer = Bluepine::Serializer.new(resolver)
serializer.serialize(hero_schema, hero)
will produce the following result
{
name: "Thor",
stats: {
strength: 8
},
friends: [
{ name: "Iron Man", stats: { strength: 9 }, friends: [], team: { name: "Avengers" } }
],
team: {
name: "Avengers"
}
}
Note: It converts number to string (via
Attribute.serializer
) and automatically adds missing fields and default value
Validating data
To validate data against defined schema. We just pass it to Validator#validate
method.
The payload could be a
Hash
or anyObject
.
payload = {
name: "Hulk",
friends: [
{ name: "Tony" },
{ name: "Sta"},
],
team: {
name: "Aven"
}
}
validator = Bluepine::Validator.new(resolver)
validator.validate(user_schema, payload) # => Result
It'll return Result
object which has 2 attributes #value
and #errors
.
In the case of errors, #errors
will contain all error messages
# Result.errors =>
{
friends: {
1 => {
name: ["is too short (minimum is 4 characters)"]
}
},
team: {
name: ["is too short (minimum is 5 characters)"]
}
}
If there's no errors, #value
will contain normalized data.
# Result.value =>
{
name: "Thor",
stats: {
strength: 0
},
friends: [
{
name: "Iron Man",
stats: { strength: 0 },
friends: [],
team: {
name: "Avengers"
}
}
],
team: { name: "Avengers" }
}
All default values will be added automatically.
Generating Open API (v3)
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver)
generator.generate # => return Open API v3 Specification
Installation
gem 'bluepine'
And then execute:
$ bundle
Or install it yourself as:
$ gem install bluepine
Attributes
Attribute
is just a simple class which doesn't have any functionality/logic on its own. With this design, it decouples the logics to validate
, serialize
, etc from Attribute
and let's consumers (e.g. Validator
, Serializer
, etc) decide it instead.
Here're pre-defined attributes that we can use.
string
- StringAttributeboolean
- BooleanAttributenumber
- NumberAttributeinteger
- IntegerAttributefloat
- FloatAttributearray
- ArrayAttributeobject
- ObjectAttributeschema
- SchemaAttribute
Creating Attribute
There're couple of ways to create attributes. We can create it manually or using some other methods.
Manually Creating Attribute
Here, we're creating it manually.
user_schema = Bluepine::Attributes::ObjectAttribute.new(:user) do
string :username
string :password
end
Using Attributes.create
This is equivalent to the above code
Bluepine::Attributes.create(:object, :user) do
string :username
string :password
end
Using Resolver
This is probably the easiest way to create object attribute. Since it also keeps track of the created attribute for you. (So, we don't have to register it by ourself. See also Resolver)
Bluepine::Resolver.new do
schema :user do
string :username
string :password
end
end
Array Attribute
Array attribute supports an option named :of
which we can use to describe what kind of data can be contained inside array
.
For example
schema :user do
string :name
# Indicates that each item inside must have the same structure
# as :user schema (e.g. friends: [{ name: "a", friends: []}, ...])
array :friends, of: :user
# i.e. pets: ["Joey", "Buddy", ...]
array :pets, of: :string
# When nothing is given, array can contain any kind of data
array :others
end
Object Attribute
Most of the time, we'll be working with this attribute more often.
schema :user do
string :name
# nested attribute
object :address do
string :street
# more nested attribute if needed
object :country do
string :name
end
end
end
Schema Attribute
Instead of declaring a lot of nested object. We can also use schema
attribute to refer to other previously defined schema (DRY).
It also accepts :of
option. (it works the same as Array
)
schema :hero do
string :name
# This implies `of: :team`
schema :team
# If the field name is different, we can specify `:of` option (which work the same way as `Array`)
schema :awesome_team, of: :team
end
schema :team do
string :name
end
Attribute Options
All attributes have common set of options avaiable
Name | type | Description | Serializer | Validator | Open API |
---|---|---|---|---|---|
name | `string\ | symbol` | Attribute's name e.g. email |
||
method | symbol |
When attribute's name differs from target's name , we can use this to specify a method that will be used to get the value for the attribute. |
read value from specified name instead. See Serializer :method . |
||
match | Regexp |
Regex that will be used to validate the attribute's value (string attribute) |
validates string based on given Regexp |
Will add Regexp to generated pattern property |
|
type | string |
Data type | Attribute's type e.g. string , schema etc |
||
native_type | string |
JSON's data type | |||
format | `string\ | symbol ` | describes the format of this value. Could be arbitary value e.g. int64 , email etc. |
||
of | symbol |
specifies what type of data will be represented in array . The value could be attribute type e.g. :string or other schema e.g. :user |
serializes data using specified value. See Serializer :of |
validates data using specified value | Create a $ref type schema |
in | array |
A set of valid options e.g. %w[thb usd ...] |
payload value must be in this list | adds to enum property |
|
if/unless | `symbol\ | proc` | Conditional validating/serializing result | serializes only when the specified value evalulates to true . See Serializer :if/:unless |
validates only when it evalulates to true |
required | boolean |
Indicates this attribute is required (for validation). Default is false |
makes it mandatory | adds to required list |
|
default | any |
Default value for attribute | uses as default value when target's value is nil |
populates as default value when it's not defined in payload | adds to default property |
private | boolean |
marks it as private . Default is false |
Excludes this attribute from serialized value | ||
deprecated | boolean |
marks this attribute as deprecated. Default is false |
adds to deprecated property |
||
description | string |
Description of attribute | |||
spec | string |
Specification of the value (for referencing only) | |||
spec_uri | string |
URI of spec |
Custom Attribute
If you want to add your own custom attribute. Simply create a new class and make it extends from Attribute
and then register it to Attributes
registry.
class AwesomeAttribute < Bluepine::Attributes::Attribute
# codes ...
end
# Register it
Bluepine::Attributes.register(:awesome, AwesomeAttribute)
Later, we can refer to it like the following.
schema :user do
string :email
awesome :cool # our custom attribute
end
Resolver
Resolver
acts as a registry that holds references to all schemas
and endpoints
that we've defined.
Manually registering schema/endpoint
user_schema = create_user_schema
# pass it to the constructor
resolver = Bluepine::Resolver.new(schemas: [user_schema], endpoints: [])
# or use `#schemas` method
resolver.schemas.register(:user, user_schema)
Automatically registering schema/endpoint
Although we can create a schema and register it manually. It'll become a tedious tasks when there're lot of schemas/endpoints to work with.
resolver = Bluepine::Resolver.new do
# schema is just `ObjectAttribute`
schema :user do
# codes
end
schema :group do
# codes
end
endpoint "/users" do
# codes
end
end
Serialization
Serializer
was designed in the way that it can serialize any type of Attribute
. Either it's simple attribute type such as StringAttribute
or a more complex type like ObjectAttribute
. The Serializer
treats it the same way.
Example
Serializing a simple type
attr = Bluepine::Attributes.create(:string, :email)
serializer.serialize(attr, 3.14) # => "3.14"
Serializing Array
attr = Bluepine::Attributes.create(:array, :heroes)
serializer.serialize(attr, ["Iron Man", "Thor"]) # => ["Iron Man", "Thor"]
Serializing Object
When serializing object, the data that we want to serialize can be a Hash
or plain Object
.
In the following example. We serialize an instance of Hero
class.
attr = Bluepine::Attributes.create(:object, :hero) do
string :name
number :power, default: 5
end
# Defines our class
class Hero
attr_reader :name, :power
def initialize(name:, power: nil)
@name = name
@power = power
end
def name
"I'm #{@name}"
end
end
thor = Hero.new(name: "Thor")
# Serializes
serializer.serialize(attr, thor) # =>
{
name: "I'm Thor",
power: 5
}
Options
:method
Value: Symbol - Alternative method name
We can use this option to specify which method of the target object that will be used to get the data from.
# Our schema
schema :hero do
string :name, method: :awesome_name
end
class Hero
def initialize(name)
@name = name
end
def awesome_name
"I'm super #{@name}!"
end
end
hero = Hero.new(name: "Thor")
# Serializes
serializer.serialize(hero_schema, hero)
will produce the following result.
{
"name": "I'm super Thor!"
}
:of
Value: Symbol
- Attribute type or Schema name e.g. :string
or :user
This option allows us to refer to other schema from array
or schema
attribute.
In the following example. We'll re-use our previously defined :hero
schema with our new :team
schema.
schema :team do
array :heroes, of: :hero
end
class Team
attr_reader :name, :heroes
def initialize(name: name, heroes: heroes)
@name = name
@heroes = heroes
end
end
team = Team.new(name: "Avengers", heroes: [
Hero.new(name: "Thor"),
Hero.new(name: "Hulk", power: 10),
])
# Serializes
serializer.serialize(team_schema, team)
will produce the following result
{
name: "Avengers",
heroes: [
{ name: "Thor", power: 5 }, # 5 is default value from hero schema
{ name: "Hulk", power: 10 },
]
}
:private
Value: Boolean
- Default is false
When it's set to true
. It'll exclude that attribute from serializer's result.
schema :hero do
string :name
number :secret_power, private: true
end
hero = Hero.new(name: "Peter", secret_power: 99)
serializer.serialize(hero_schema, hero)
will exclude secret_power
from result
{
name: "Peter"
}
Conditional Serialization
:if/:unless
Possible value: Symbol
/Proc
This enables us to serialize value based on if/unless
conditions.
schema :hero do
string :name
# :mode'll get serialized only when `dog_dead` is true
string :mode, if: :dog_dead
# or we can use `Proc` e.g.
# string :mode, if: ->(x) { x.dog_dead }
boolean :dog_dead, default: false
end
hero = Hero.new(name: "John Wick", mode: "Angry")
serializer.serialize(hero_schema, hero) # =>
will produce
{
name: "John Wick",
dog_dead: false
}
But if we set dog_dead: true
the result will include mode
value.
{
name: "John Wick",
mode: "Angry",
dog_dead: true,
}
Custom Serializer
By default, each primitive types e.g. string
, integer
, etc. has its own serializer. We can override it by overriding .serializer
class method.
For example. If we want to extend boolean
attribute to treat "on" as a valid boolean value. We could do it like this.
BooleanAttribute.normalize = ->(x) { ["on", true].include?(x) ? true : false }
# Usage
schema :team do
boolean :active
end
team = Team.new(active: "on")
serializer.serialize(team_schema, team)
will produce
{
active: true
}
Endpoint
Endpoint represents the API endpoint and it's operations e.g. GET
, POST
, etc.
It groups resource's related operations together and defines a set of valid parameters that the endpoint accepts.
Defining Endpoint
We could define it manually as follows
Bluepine::Endpoint.new "/users" do
get :read, path: "/:id"
end
or defines it via Resolver
Bluepine::Resolver.new do
endpoint "/heroes" do
post :create, path: "/"
end
endpoint "/teams" do
# code
end
end
Method
Endpoint provides a set of http methods such as get
, post
, patch
, delete
, etc.
Each method expects a name and some other options.
Note that name must be unique within endpoint
method(name, path:, params:)
# e.g.
get :read, path: "/:id"
post :create, path: "/"
Params
Params
allows us to define a set of valid parameters accepted by the Endpoint's methods (e.g. get
, post
, etc).
We can think of Params
the same way as Schema
(i.e. ObjectAttribute
). It's just a specialize version of ObjectAttribute
.
Defining default params
endpoint "/users" do
# declare default params
params do
string :username
string :password
end
# `params: true` will use default params for validating incoming request
post :create, params: true
# this will re-use `username` param from default params
patch :update, params: i[username]
end
Using no params params: false
(default behaviour)
If we don't want our endpoint's method to re-use default params. We can specify params: false
to endpoint method's arguments.
Note: this is the default behaviour. So we can leave it blank.
get :index, path: "/" # ignore `params` means `params: false`
Using default params params: true
As we've seen in the example above. Set params: true
indicates that we want to use default params for this method.
post :create, path: "/", params: true
Using subset of default params's attributes params: %i[...]
Let's say we want to use only some of default params's attrbiute e.g. currency
(but not other attributes). We can specify it like this.
patch :update, path: "/:id", params: i[currency]
In this case it will re-use only currency
attribute for validation.
Excluding some of default params's attributes exclude: true
Let's say the update
method doesn't need the default params
's amount
attribute (but still want to use all other attributes). We can specify it as follows.
patch :update, path: "/:id", params: i[amount], exclude: true
Overriding default params with params: Proc
In the case where we want to completely use the new set of params. We can use Proc
to define it like the following.
# inside schema.endpoint block
patch :update, path: "/:id", params: lambda {
integer :max_amount, required: true
string :new_currency, match: /\A[a-z]{3}\z/
}
It will use these new params for validating/generating specs.
Re-using Params from Other Service params: Symbol
We can also re-use params from other endpoint by specifing a Symbol
that refers to other endpoint's params.
endpoint "/search" do
params do
string :query
number :limit
end
end
endpoint "/blogs" do
get :index, path: "/", params: :search
end
Here we will use search
endpoint's default params for validating GET /users
endpoint.
Endpoint Validation
See Validation - Validating Endpoint
Validation
Once we have our schema/endpoint defined. We can use validator to validate it against any data. (it uses ActiveModel::Validations
under the hood)
Similar to Serializer
. We can use Validator
to validate any type of Attribute
.
Example
Validating simple attribute
attr = Bluepine::Attributes.create(:string, :email)
email = true
validator.validate(attr, email) # => Result object
In this case, it'll just return a Result.errors
which contain error message
["is not string"]
Validating Array
attr = Bluepine::Attributes.create(:array, :names, of: :string)
names = ["john", 1, "doe"]
validator.validate(attr, names) # => Result object
It'll return the error messages at exact index position.
{
1 => ["is not string"]
}
Validating Object
Most of the time, we'll work with the object type (instead of simple type like string
, etc).
attr = Bluepine::Attributes.create(:object, :user) do
string :username, min: 4
string :password, min: 10
end
user = {
username: "john",
password: true,
}
validator.validate(attr, user) # => Result object
Since it's an object, the errors will contain attribute names
{
password: [
"is not string",
"is too short (minimum is 10 characters)"
]
}
Options
:required
Value: Boolean
- Default is false
This option makes the attribute mandatory.
schema :hero do
string :name, required: true
end
hero = Hero.new
validator.validate(hero_schema, hero) # => Result.errors
will return
{
name: ["can't be blank"]
}
:match
Value: Regexp
- Regular Expression to be tested.
This option will test if string matches against given regular expression or not.
schema :hero do
string :name, match: /\A[a-zA-Z]+\z/
end
hero = Hero.new(name: "Mark 3")
validator.validate(hero_schema, hero) # => Result.errors
will return
{
name: ["is not valid"]
}
:min/:max
Value: Number
- Apply to both string
and number
attribute types.
This option sets a minimum and maximum value for attribute.
schema :hero do
string :power, max: 100
end
hero = Hero.new(power: 200)
validator.validate(hero_schema, hero) # => Result.errors
will return
{
power: ["must be less than or equal to 100"]
}
:in
Value: Array
- Set of valid values.
This option will test if value is in the specified list or not.
schema :hero do
string :status, in: ["Happy", "Angry"]
end
hero = Hero.new(status: "Mad")
validator.validate(hero_schema, hero) # => Result.errors
will return
{
status: ["is not included in the list"]
}
Conditional Validation
:if/:unless
Possible value: Symbol
/Proc
This enables us to validate attribute based on if/unless
conditions.
schema :hero do
string :name
# or we can use `Proc` e.g.
# if: ->(x) { x.is_agent }
string :agent_name, required: true, if: :is_agent
boolean :agent, default: false
end
hero = Hero.new(name: "Nick Fury", is_agent: true)
validator.validate(hero_schema, hero) # Result.errors =>
will produce (because is_agent
is true
)
{
agent_name: ["can't be blank"]
}
Custom Validator
Since the validator is based on ActiveModel::Validations
. This make it easy to add a new custom validator.
In the following example. We create a simple password validator and register it to the password attribute.
# Defines custom validator
class CustomPasswordValidator < ActiveModel::Validator
def validate(record)
record.errors.add(:password, "is too short") unless record.password.length > 10
end
end
# Registers
schema :user do
string :username
string :password, validators: [CustomPasswordValidator]
end
Custom Normalizer
It's possible to change the logic for normalizing data before passing it to the validator. For example, you might want to normalize boolean
value before validating it.
Here, we want to normalize string such as on
or 1
to boolean true
first.
# Overrides default normalizer
BooleanAttribute.normalizer = ->(x) { [true, 1, "on"].include?(x) ? true : false }
schema :hero do
boolean :berserk
end
hero = Hero.new(berserk: 1)
validator.validate(hero_schema, hero) # Result.value
will pass the validation and Result.value
'll contain normalized value
{
berserk: true # convert 1 to true
}
Validating Endpoint
All examples above also apply to endpoint's parameters validation.
Because the params is part of Endpoint
and it's non-trivial task to retrieve endpoint's method's params. So, the Endpoint
provides some helper methods to validate the data.
resolver = Bluepine::Resolver.new do
endpoint "/heroes" do
post :create, params: lambda {
string :name, required: true
}
end
end
# :create is a POST method name given to the endpoint.
resolver.endpoint(:heroes).method(:create, resolver: resolver).validate(payload) # => Result
Generating Open API (v3)
Once we have all schemas/endpoints defined and registered to the Resolver
. We can simply pass it to the generator as follows.
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver, )
generator.generate # =>
will output Open API (v3) specs
excerpt from the full result
// endpoints
"/users": {
"post": {
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"accepted": {
"type": "boolean",
"enum": [true, false]
},
}
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/user"
}
}
}
}
}
}
}
// schema
"user": {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"city": {
"type": "string",
"default": "Bangkok"
}
}
},
"friends": {
"type": "array",
"items": {
"$ref": "#/components/schemas/user"
}
}
}
}
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/omise/bluepine. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.