Bluepine
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.
Table of contents
- Quick Start
- Defining a Schema
- Serializing Schema
- Generating Open API (v3)
- Installation
- Attributes
- Creating Attribute
- Attribute Options
- Custom Attribute
- Resolver
- Manually registering schema/endpoint
- Automatically registering schema/endpoint
- Serialization
- Example
- Conditional Serialization
- Custom Serializer
- Endpoint
- Method
- Params
- Validation
- Permitted Params (Rails)
- Validation
- Conditional Validation
- Custom Normalizer
- Custom Validator
- Generating API Specifications
- Open API (v3)
Quick start
Defining a schema
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
Resolverto 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
endSerializing schema
To serialize schema, just pass the schema defined in the previous step to Serializer.
The object to be serialized can be a
Hashor anyObjectwith 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. pass the data to the Validator#validate method.
The payload could be a
Hashor anyObject.
payload = {
name: "Hulk",
friends: [
{ name: "Tony" },
{ name: "Sta"},
],
team: {
name: "Aven"
}
}
validator = Bluepine::Validator.new(resolver)
validator.validate(user_schema, payload) # => ResultThis 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.
Generating Open API (v3)
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver)
generator.generate # => return Open API v3 SpecificationInstallation
gem 'bluepine'
And then execute:
$ bundle
Or install it yourself as:
$ gem install bluepine
Attributes
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- StringAttribute -
boolean- BooleanAttribute -
number- NumberAttribute -
integer- IntegerAttribute -
float- FloatAttribute -
array- ArrayAttribute -
object- ObjectAttribute -
schema- SchemaAttribute
Creating an attribute
There are a multiple ways to create attributes. We can create it manually or by using other methods.
Manually creating an attribute
The following example creates an attribute manually.
user_schema = Bluepine::Attributes::ObjectAttribute.new(:user) do
string :username
string :password
endUsing Attributes.create
This is equivalent to the code mentioned previously.
Bluepine::Attributes.create(:object, :user) do
string :username
string :password
endUsing Resolver
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
endArray attribute
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
endObject attribute
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
endSchema attribute
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
endAttribute options
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
|
Custom attribute
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
endResolver
Resolver acts as a registry that holds the references to all schemas and endpoints that we have 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
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
endSerialization
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.
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 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"
}Conditional serialization
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,
}Custom serializer
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
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.
Defining endpoint
We could define it manually as follows:
Bluepine::Endpoint.new "/users" do
get :read, path: "/:id"
endor define it via Resolver:
Bluepine::Resolver.new do
endpoint "/heroes" do
post :create, path: "/"
end
endpoint "/teams" do
# code
end
endEndpoint 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.
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 requests
post :create, params: true
# this will re-use the `username` param from default params
patch :update, params: %i[username]
endUsing no params params: false (default behaviour)
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`Using default params params: true
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: trueUsing subset of default params' attributes params: %i[...]
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.
Excluding some of default params' attributes exclude: true
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: trueOverriding default params with params: Proc
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.
Re-using params from other service params: Symbol
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
endThe default params of the search endpoint are now used for validating the GET /users endpoint.
Endpoint validation
See Validation - Validating Endpoint
Validation
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.
Example
Validating simple attribute
attr = Bluepine::Attributes.create(:string, :email)
email = true
validator.validate(attr, email) # => Result objectIn this case, it will just return a Result.errors that contains an error message.
["is not string"]Validating Array
attr = Bluepine::Attributes.create(:array, :names, of: :string)
names = ["john", 1, "doe"]
validator.validate(attr, names) # => Result objectIt will return the error messages at the exact index position.
{
1 => ["is not string"]
}Validating Object
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 objectSince it is an object, the errors will contain attribute names:
{
password: [
"is not string",
"is too short (minimum is 10 characters)"
]
}Options
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.errorswill 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.errorswill 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.errorswill 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.errorswill 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"]
}Custom validator
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]
endCustom normalizer
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.valuewill pass the validation and Result.value will contain the normalized value:
{
berserk: true # convert 1 to true
}Validating Endpoint
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) # => ResultGenerating Open API (v3)
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"
}
}
}
}Contributing
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.
License
The gem is available as open source under the terms of the MIT License.