Traditionally, the JSON response from a Rails API server isn't typed. So even if we have TypeScript at the front-end, we still have little guarantee that our back-end would return the correct type and structure of data.
In order to eliminate type mismatch between both ends, Camille provides a syntax for you to define type schema for your Rails API, and uses these schemas to generate the TypeScript functions for calling the API.
For example, an endpoint defined in Ruby, where data
is a controller action,
get :data do
params(
id: Number
)
response(
name: String
)
end
will become a function in TypeScript:
data(params: {id: number}): Promise<{name: string}>
Therefore, if the front-end requests the API by calling data
, we have guarantee that id
is presented in params
, and Camille will require the response to contain a string name
, so the front-end can receive the correct type of data.
By using these request functions, we also don't need to know about HTTP verbs and paths. It's impossible to have unrecognized routes, since Camille will make sure that each function handled by the correct Rails action.
There's a step by step tutorial for setting up and showcasing Camille: https://github.com/onyxblade/camille-tutorial.
Add this line to your application's Gemfile:
gem 'camille'
And then execute:
bundle install
bundle exec rails g camille:install
A schema defines the type of params
and response
for a controller action. The following commands will generate schema definition files in config/camille/schemas
.
# to generate a schema for ProductsController
bundle exec rails g camille:schema products
# to generate a schema for Api::ProductController
bundle exec rails g camille:schema api/products
An example of schema definition:
using Camille::Syntax
class Camille::Schemas::Api::Products < Camille::Schema
include Camille::Types
get :data do
params(
id: Number
)
response(
name: String
)
end
end
The Api::Products
schema defines one endpoint data
and its params and response type. This endpoint corresponds to the data
action on Api::ProductsController
. Inside the action, you can assume that params[:id]
is a number, and you will need to render json: {name: 'some string'}
in order to pass the typecheck.
When generating TypeScript request functions, the data
endpoint will become a function having the following signature:
data(params: {id: number}): Promise<{name: string}>
Therefore, the front-end user is required to provide an id
when they call this function. And they can expect to get a name
from the response of this request. There are no more type mismatch between both ends.
The params
type for an endpoint is required to be an object type, or a hash in Ruby, while response
type can be any supported type, for example a Boolean
.
Camille will automatically add a Rails route for each endpoint. You don't need to do anything other than having the schema file in place.
When defining an endpoint, you can also use post
instead of get
for non-idempotent requests. However, no other HTTP verbs are supported, because verbs in RESTful like patch
and delete
indicate what we do on resources, but in RPC-style design each request is merely a function call that does not concern RESTful resources.
In addition to primitive types, you can define custom types in Camille. The following commands will generate type definition files in config/camille/types
.
# to generate a type named Product
rails g camille:type product
# to generate a type named Nested::Product
rails g camille:type nested/product
An example of custom type definition:
using Camille::Syntax
class Camille::Types::Product < Camille::Type
include Camille::Types
alias_of(
id: Number,
name: String
)
end
Each custom type is considered a type alias in TypeScript. And alias_of
defines what this type is aliasing. In this case, the Product
type is an alias of an object type having fields id
as Number
and name
as String
. When generating TypeScript, it will be converted to the following:
type Product = {id: number, name: string}
You can perform a type check on a value using test
, which might be handy in testing:
error = Camille::Types::Product.test(hash)
if error.nil?
# the hash is accepted by Camille::Types::Product type
else
p error
end
Camille supports most of the type syntax in TypeScript. Below is a list of types that you can use in type and schema definition.
params(
# primitive types in TypeScript
number: Number,
string: String,
boolean: Boolean,
null: Null,
undefined: Undefined,
any: Any,
# an array type is a type name followed by '[]'
array: Number[],
# an object type looks like hash
object: {
field: Number
},
# an array of objects also works
object_array: {
field: Number
}[]
# a union type is two types connected by '|'
union: Number | String,
# an intersection type is two types connected by '&'
intersection: { id: Number } & { name: String },
# a tuple type is several types put inside '[]'
tuple: [Number, String, Boolean],
# a field followed by '?' is optional, the same as in TypeScript
optional?: Number,
# literal types
number_literal: 1,
string_literal: 'hello',
boolean_literal: false,
# a custom type we defined above
product: Product,
# Pick and Omit accept a type and an array of symbols
pick: Pick[{a: 1, b: 2}, [:a, :b]],
omit: Omit[Product, [:id]],
# Record accepts a key type and a value type
record: Record[Number, String]
)
After you have your types and schemas in place, you can visit /camille/endpoints.ts
in development environment to have the TypeScript request functions generated.
An example from our previously defined type and schema will be:
import request from './request'
export type Product = {id: number, name: string}
export default {
api: {
data(params: {id: number}): Promise<{name: string}> {
return request('get', '/api/products/data', params)
}
}
}
The first line of import
is configurable as config.ts_header
in config/camille/configuration.rb
. You would need to implement a request
function that performs the HTTP request.
In TypeScript world, people usually use camelCase to name functions and variables, while in Ruby the convention is to use snake_case. Camille will automatically convert between these two when processing request.
For example,
get :special_data do
params(
long_id: Number
)
response(
long_name: String
)
end
will have TS signature:
specialData(params: {longId: number}): Promise<{longName: string}>
In the Rails action you still use params[:long_id]
to access the parameter and return long_name
in response.
If a controller action has a corresponding schema, Camille will raise an error if the returned JSON doesn't match the response type specified in the schema.
For example for
response(
object: {
array: Number[]
}
)
if we return such a JSON in our action
render json: {
object: {
array: [1, 2, '3']
}
}
Camille will print the following error:
object:
array:
array[2]: Expected number, got "3".
Everything in config/camille/types
and config/camille/schemas
will automatically reload after changes in development environment, just like other files in Rails.
Run tests with bundle exec rake
.
Bug reports and pull requests are welcome on GitHub at https://github.com/onyxblade/camille.