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
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
endTo 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:
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.
generator = Bluepine::Generators::OpenAPI::Generator.new(resolver)
generator.generate # => return Open API v3 Specificationgem '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
endThis is equivalent to the code mentioned previously.
Bluepine::Attributes.create(:object, :user) do
string :username
string :password
endThis 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 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
endMost 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
endInstead 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
endAll 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
endResolver 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
endSerializer 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"
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.
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]
endIf 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: trueAssume 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: trueTo 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
endThe 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 objectIn 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 objectIt 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 objectSince 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.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"]
}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]
endIt 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
}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) # => ResultOnce 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.