Bluepine
is a DSL for defining API Schema/Endpoint with the capabilities to generate the Open API (v3)
spec (other specs are coming soon), validate API requests and serialize objects for API response based on single schema definition.
- Quick Start
- Installation
- Attributes
- Resolver
- Serialization
- Endpoint
- Validation
- Generating API Specifications
Let's start by creating a simple schema. (For a complete list of attributes and their options, please see the Attributes section.)
We can create and register a schema as two seperate steps, 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
To serialize schema, just pass the schema defined in the 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:
To validate data against defined schema. pass the data to the 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
This method returns a Result
object that 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 are 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 the default values will be added automatically.
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver)
generator.generate # => return Open API v3 Specification
gem 'bluepine'
And then execute:
$ bundle
Or install it yourself as:
$ gem install bluepine
Attribute
is just a simple class that doesn't have any functionality/logic on its own. With this design, it decouples the logic to validate
, serialize
, etc from Attribute
and lets consumers (e.g. Validator
, Serializer
, etc) decide the logic instead.
Here are the pre-defined attributes that we can use.
string
- StringAttributeboolean
- BooleanAttributenumber
- NumberAttributeinteger
- IntegerAttributefloat
- FloatAttributearray
- ArrayAttributeobject
- ObjectAttributeschema
- SchemaAttribute
There are a multiple ways to create attributes. We can create it manually or by using other methods.
The following example creates an attribute manually.
user_schema = Bluepine::Attributes::ObjectAttribute.new(:user) do
string :username
string :password
end
This is equivalent to the code mentioned previously.
Bluepine::Attributes.create(:object, :user) do
string :username
string :password
end
This is probably the easiest way to create an object attribute. This method keeps track of the created attribute for you, and you don't have to register it manually. See also Resolver)
Bluepine::Resolver.new do
schema :user do
string :username
string :password
end
end
Array attribute supports an option named :of
that we can use to describe the kind of data that can be contained inside an 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
Most of the time, we'll be working with this attribute.
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
Instead of declaring many nested objects. we can use the schema
attribute to refer to other previously defined schema (DRY).
The Schema attribute also accepts the :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 (that works the same way as `Array`)
schema :awesome_team, of: :team
end
schema :team do
string :name
end
All attributes have a common set of options.
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. |
This will be added to the format property |
||
of | symbol |
Specifies the type of data that will be represented in an 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 is 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 |
To add your custom attribute. create a new class, make it extend from Attribute
, and then register it with the Attributes
registry.
class AwesomeAttribute < Bluepine::Attributes::Attribute
# codes ...
end
# Register it
Bluepine::Attributes.register(:awesome, AwesomeAttribute)
Later, we can refer to it as follows.
schema :user do
string :email
awesome :cool # our custom attribute
end
Resolver
acts as a registry that holds the references to all schemas
and endpoints
that we have defined.
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)
Manually creating and registering a schema becomes tedious when there are many schemas and endpoints to work with. The following example demonstrates how to automatically register a schema/endpoint.
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
Serializer
was designed to serialize any type of Attribute
- both a simple attribute type such as StringAttribute
or a more complex type such as ObjectAttribute
. The Serializer
treats both types alike.
attr = Bluepine::Attributes.create(:string, :email)
serializer.serialize(attr, 3.14) # => "3.14"
attr = Bluepine::Attributes.create(:array, :heroes)
serializer.serialize(attr, ["Iron Man", "Thor"]) # => ["Iron Man", "Thor"]
When serializing an object, the data that we want to serialize can either be a Hash
or a plain Object
.
In the following example. we serialize an instance of the 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
}
Value: Symbol - Alternative method name
Use this option to specify the method of the target object from which to get the data.
# 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!"
}
Value: Symbol
- Attribute type or Schema name e.g. :string
or :user
This option allows us to refer to other schema from the array
or schema
attribute.
In the following example. we 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)
The result is as follows:
{
name: "Avengers",
heroes: [
{ name: "Thor", power: 5 }, # 5 is default value from hero schema
{ name: "Hulk", power: 10 },
]
}
Value: Boolean
- Default is false
Set this to true
to exclude that attribute from the 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 the result:
{
name: "Peter"
}
Possible value: Symbol
/Proc
Serializes the value based on if/unless
conditions.
schema :hero do
string :name
# :mode will 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
}
However, if we set dog_dead: true
, the result will include mode
value.
{
name: "John Wick",
mode: "Angry",
dog_dead: true,
}
By default, each primitive type e.g. string
, integer
, etc. has its own serializer. We can override it by overriding the .serializer
class method.
For example. to extend the boolean
attribute to treat "on" as a valid boolean value, use the following code.
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)
Result:
{
active: true
}
Endpoint represents the API endpoint and its operations e.g. GET
, POST
, etc. Related operations for a resource are grouped together along with a set of valid parameters that the endpoint accepts.
We could define it manually as follows:
Bluepine::Endpoint.new "/users" do
get :read, path: "/:id"
end
or define it via Resolver
:
Bluepine::Resolver.new do
endpoint "/heroes" do
post :create, path: "/"
end
endpoint "/teams" do
# code
end
end
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 the name must be unique within an endpoint.
method(name, path:, params:)
# e.g.
get :read, path: "/:id"
post :create, path: "/"
Params
help 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
). They are just a specialized version of ObjectAttribute
.
endpoint "/users" do
# declare default params
params do
string :username
string :password
end
# `params: true` will use default params for validating incoming requests
post :create, params: true
# this will re-use the `username` param from default params
patch :update, params: %i[username]
end
If we don't want our endpoint's method to use default params, we can specify params: false
in the endpoint method's arguments.
Note: this is the default behaviour. So we can leave it blank.
get :index, path: "/" # ignore `params` means `params: false`
As we've seen in the previous example, params: true
indicates that we want to use default params for this method.
post :create, path: "/", params: true
Assume that we want to use only some of the default params' attrbutes, e.g. currency
(but not other attributes). We can specify it as follows.
patch :update, path: "/:id", params: %i[currency]
In this case, it will use only currency
attribute for validation.
Let's say the update
method doesn't need the amount
attribute from the default params
(but still want to use all other attributes). We can specify it as follows.
patch :update, path: "/:id", params: %i[amount], exclude: true
To completely use a new set of params, use Proc
to define them as follows.
# inside schema.endpoint block
patch :update, path: "/:id", params: lambda {
integer :max_amount, required: true
string :new_currency, match: /\A[a-z]{3}\z/
}
The new params are then used for validating and generating specs.
We can also re-use params from other endpoints by specifing a Symbol
that refers to the params of the other endpoint.
endpoint "/search" do
params do
string :query
number :limit
end
end
endpoint "/blogs" do
get :index, path: "/", params: :search
end
The default params of the search
endpoint are now used for validating the GET /users
endpoint.
See Validation - Validating Endpoint
Once we have our schema/endpoint defined, we can use the 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
.
attr = Bluepine::Attributes.create(:string, :email)
email = true
validator.validate(attr, email) # => Result object
In this case, it will just return a Result.errors
that contains an error message.
["is not string"]
attr = Bluepine::Attributes.create(:array, :names, of: :string)
names = ["john", 1, "doe"]
validator.validate(attr, names) # => Result object
It will return the error messages at the exact index position.
{
1 => ["is not string"]
}
Most of the time, we'll work with the object type (instead of simple type such as 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 is an object, the errors will contain attribute names:
{
password: [
"is not string",
"is too short (minimum is 10 characters)"
]
}
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"]
}
Value: Regexp
- Regular Expression to be tested.
This option will test if string matches against the 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"]
}
Value: Number
- Apply to both string
and number
attribute types.
This option sets a minimum and maximum value for the 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"]
}
Value: Array
- Set of valid values.
This option will test if the 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"]
}
Possible value: Symbol
/Proc
This enables us to validate the 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"]
}
Since the validator is based on ActiveModel::Validations
, it is 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
It is possible to change the logic for normalizing data before passing it to the validator. For example, you might want to normalize the boolean
value before validating it.
Here, we want to normalize a string such as on
or 1
to boolean true
.
# 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
will contain the normalized value:
{
berserk: true # convert 1 to true
}
All the preceding examples also apply to validation of endpoint parameters.
As the params are part of the Endpoint
and it is non-trivial to retrieve the params of the endpoint's methods, 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
Once we have all schemas/endpoints defined and registered to the Resolver
, we can pass it to the generator as follows.
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver, options)
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"
}
}
}
}
Bug reports and pull requests are welcome on GitHub. 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.
The gem is available as open source under the terms of the MIT License.