Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/onyxblade/camille-tutorial


https://github.com/onyxblade/camille-tutorial

Last synced: 16 days ago
JSON representation

Awesome Lists containing this project

README

        

# Camille Tutorial

This is a tutorial for the gem [Camille](https://github.com/onyxblade/camille). Through this tutorial, you'll learn how to setup a Rails project with Camille, define type schema for controller actions, generate type-safe request functions in TypeScript and how to use them in a frontend project. You'll also get a grasp on how Camille ensure type safety across frontend and backend, and also how to define custom types if you need them.

The tutorial is organized by many stages. You can jump to any stage N by `git checkout -f stage-N` if you feel lost. Note that `git checkout -f` will discard any changes in your current workspace.

Now let's clone this repository and jump to `stage-0` that is prepared for you.

```shell
git clone https://github.com/onyxblade/camille-tutorial.git
cd camille-tutorial
git checkout stage-0
```

If you're curious, `stage-0` was made by these two commands:

```shell
rails new backend --api
npm create vite@latest frontend -- --template vanilla-ts
```

## Stage 1: Setup Rails

During this stage, we will setup the model and controller we need in Rails. And we will install `camille` and enable `rack-cors` to allow access from the frontend project.

```shell
cd backend

# Create a Book model and do the migration
rails g model Book name:string author:string retail_price:decimal
rails db:create
rails db:migrate

# Create a controller for books
rails g controller books
```

Add the following lines to `Gemfile`:
```ruby
gem 'camille', '0.5.0'
gem 'rack-cors'
```

Then run
```shell
bundle install
rails g camille:install
```

The `config/camille` folder will be created. We will add types and schemas into this folder later.

Finally, we need to configure `rack-cors` by replacing `config/initializers/cors.rb` with the following:

```ruby
# backend/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "*"

resource "*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
```

Now we completed Stage 1. You can skip this stage by directly jumping to `stage-1` with `git checkout -f stage-1`.

## Stage 2: Setup Camille schema

In this stage we will create a typed schema for `books` controller and see how type checking works.

Let's run this generator command:
```shell
rails g camille:schema books
```

A Camille schema file will be created in `config/camille/schemas/books.rb`. This schema defines the parameters and response type for actions in `BooksController`.

We can now add an action `hello` to the schema, which takes no parameters and returns a message in response. The resulting file should be:
```ruby
# backend/config/camille/schemas/books.rb
using Camille::Syntax

class Camille::Schemas::Books < Camille::Schema
include Camille::Types

get :hello do
response(
message: String
)
end
end
```

We also add a `hello` method to `BooksController`:
```ruby
# backend/app/controllers/books_controller.rb
class BooksController < ApplicationController
def hello
render json: {
message: 'Hello World.'
}
end
end
```

Now start the Rails server and visit `http://127.0.0.1:3000/books/hello`. You should be able to see the message in JSON response.

Then let's visit `http://127.0.0.1:3000/camille/endpoints.ts`. We'll see typed request functions generated with the types defined in `schemas/books.rb`, as the following:

```typescript
// This file is automatically generated.
import request from './request'

export type DateTime = string
export type Decimal = number

export default {
books: {
hello(): Promise<{message: string}>{ return request('get', '/books/hello', {}) },
},
}
```

We'll show how to use this TypeScript file in the next stage.

Now if you change the response of `hello` action to
```ruby
# backend/app/controllers/books_controller.rb
class BooksController < ApplicationController
def hello
render json: {
message: 1
}
end
end
```

and visit `http://127.0.0.1:3000/books/hello` again, the following error will be printed:
```
Type check failed for response.
message: Expected string, got 1.
```

Since we defined that `message` to be a `String`, we can only pass a string into the `message` field, or Camille will raise an error to warn us. Therefore, our response for an action is guaranteed to have the correct type, so the frontend caller won't be surprised.

Now we completed stage 2. As usual, you can check changes for this stage by `git show stage-2` and skip the state by `git checkout -f stage-2`.

## Stage 3: Setup frontend

During this stage, we will set up the `frontend` project, making it capable to request the backend.

Previously we saw a TypeScript file generated at `http://127.0.0.1:3000/camille/endpoints.ts`. We need to copy it into the `frontend/src` folder so it can be read by the frontend project. Copying it manually can be tedious, so we will now add a `npm run sync` command for this purpose.

Firstly let's add `axios` to the frontend project.

```shell
# Make sure you're in the frontend folder
npm i axios
```

Then we create a `sync.js` under `frontend` folder that requests our backend and saves the `endpoints.ts`:

```javascript
// frontend/sync.js
import axios from 'axios'
import fs from 'fs'

const BASE_URL = 'http://localhost:3000'

axios.get(`${BASE_URL}/camille/endpoints.ts`)
.then(response => {
fs.writeFileSync('src/endpoints.ts', response.data)
})
.catch(error => {
console.log(error)
})
```

And then we add `"sync": "node sync.js"` to the `"scripts"` section in `package.json`. The resulting `package.json` should be:

```json
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"sync": "node sync.js"
},
"devDependencies": {
"typescript": "^4.9.3",
"vite": "^4.2.0"
},
"dependencies": {
"axios": "^1.3.4"
}
}
```

Now make sure our backend is running, and we run `npm run sync` under the `frontend` folder. An `endpoints.ts` should be created under `frontend/src/` folder.

If you open `endpoints.ts` in editor, you'd probably notice that there's a type error, since it imported a `request` function, but we haven't created that. Camille only provides type definition for API endpoints, so it still relies on the user to provide a function that actually does the HTTP request.

We will create the function as following, in `frontend/src/request.ts`:
```typescript
// frontend/src/request.ts
import axios from 'axios'

const BASE_URL = 'http://localhost:3000'

export default function request(method: 'get' | 'post', path: string, params: any) {
const config = method === 'get' ? { params: params } : { data: params }
return axios({
method: method,
url: `${BASE_URL}${path}`,
...config
}).then(response => response.data)
}
```

Now if we go back to `endpoints.ts` the error should be gone. The setup is now completed, and in the next stage we will actually do the request.

## Stage 4: Calling hello

This stage is relatively simple. We just add a button to our frontend project that requests the backend and shows the response message.

We rewrite the `main.ts` into the following:
```typescript
// frontend/src/main.ts
import endpoints from "./endpoints"

document.querySelector('#app')!.innerHTML = `


Hello

`

document.querySelector('#hello')?.addEventListener('click', async () => {
const data = await endpoints.books.hello()
alert(data.message)
})
```

Now if you hover your mouse on the `data` variable, you should see that it has one `message` field as a string. This means that our code is typecheked. You won't be able to access other fields of `data` without TypeScript yelling on it.

We can then start the frontend server:

```shell
# Make sure you're in the frontend folder
npm run dev
```

and visit `http://localhost:5173/`. Clicking the button should show the hello message.

Since the `endpoints` object is generated by our backend, we don't need to care about HTTP methods or paths. They would never be wrong. We just need to call the function on it, and it'll return the data we intended.

## Stage 5: Create a book

During this stage, we will add an endpoint handling creation of a book, and call it from the frontend.

Let's add an endpoint `create` to `backend/config/camille/schemas/books.rb`:

```ruby
# backend/config/camille/schemas/books.rb
using Camille::Syntax

class Camille::Schemas::Books < Camille::Schema
include Camille::Types

get :hello do
response(
message: String
)
end

post :create do
params(
book: {
name: String,
author: String,
retail_price: Decimal
}
)
response(Boolean)
end
end
```

This endpoint receives the parameters for a book, and returns a boolean indicating whether the book is saved. We also add a `create` action to `BooksController`:

```ruby
# backend/app/controllers/books_controller.rb
class BooksController < ApplicationController
def hello
render json: {
message: 'Hello World.'
}
end

def create
book = Book.create(book_params)
render json: book.persisted?
end

private
def book_params
params.require(:book).permit(:name, :author, :retail_price)
end
end
```

Since we have changed the schema, we need to run `npm run sync` again in the frontend project.

Then we can add another button to `main.ts` to create a book.

```typescript
// frontend/src/main.ts
import endpoints from "./endpoints"

document.querySelector('#app')!.innerHTML = `


Hello
Create Book

`

document.querySelector('#hello')?.addEventListener('click', async () => {
const data = await endpoints.books.hello()
alert(data.message)
})

document.querySelector('#create')?.addEventListener('click', async () => {
const data = await endpoints.books.create({
book: {
name: 'Metaprogramming Ruby',
author: 'Paolo Perrotta',
retailPrice: 27.95
}
})
alert(data ? "Created book" : "Error happened")
})
```

Now if we click the `Create Book` button, we should see in the log of our backend that a new book has been inserted into the database. Note that our request parameters are all typechecked, so it's guaranteed that our Rails server will receive them in `params`.

One cool feature of Camille is that it'll automatically converts between camelCase in TypeScript and snake_case in Ruby. So at frontend we pass the price as `retailPrice` but at backend we still operate on `retail_price`. This allows us to follow the naming convention in both worlds.

## Stage 6: Retrieve a book

You probably know very well how to implement an endpoint for retrieving the book, just add a `get :show` with the fields of a book. But since the fields would likely be used in many actions, we can benefit from defining a custom type for `Book`, so we no longer need to write them one by one in the schema.

We can generate a custom type in the backend folder by this command:

```shell
rails g camille:type book
```

Then we add fields to the newly generated type:
```ruby
# backend/config/camille/types/book.rb
using Camille::Syntax

class Camille::Types::Book < Camille::Type
include Camille::Types

alias_of(
id: Number,
name: String,
author: String,
retail_price: Decimal
)
end
```

The `Book` type is a type alias of the object type having those fields. If you look at the generated `endpoints.ts`, it will be there:
```typescript
export type Book = {id: number, name: string, author: string, retailPrice: Decimal}
```

We also add a new endpoint `show` and a controller action `show`:

```ruby
# backend/config/camille/schemas/books.rb
using Camille::Syntax

class Camille::Schemas::Books < Camille::Schema
include Camille::Types

get :hello do
response(
message: String
)
end

post :create do
params(
book: {
name: String,
author: String,
retail_price: Decimal
}
)
response(Boolean)
end

get :show do
params(
id: Number
)
response(
book: Book
)
end
end
```

```ruby
# backend/app/controllers/books_controller.rb
class BooksController < ApplicationController
def hello
render json: {
message: 'Hello World.'
}
end

def create
book = Book.create(book_params)
render json: book.persisted?
end

def show
book = Book.find(params[:id])
render json: {
book: {
id: book.id,
name: book.name,
author: book.author,
retail_price: book.retail_price
}
}
end

private
def book_params
params.require(:book).permit(:name, :author, :retail_price)
end
end
```

Now after running `npm run sync`, we are ready to get a book at frontend.

We add a button to retreive the first book to `main.ts`:

```typescript
// frontend/src/main.ts
import endpoints from "./endpoints"

document.querySelector('#app')!.innerHTML = `


Hello
Create Book
Show the first Book

`

document.querySelector('#hello')?.addEventListener('click', async () => {
const data = await endpoints.books.hello()
alert(data.message)
})

document.querySelector('#create')?.addEventListener('click', async () => {
const data = await endpoints.books.create({
book: {
name: 'Metaprogramming Ruby',
author: 'Paolo Perrotta',
retailPrice: 27.95
}
})
alert(data ? "Created book" : "Error happened")
})

document.querySelector('#show')?.addEventListener('click', async () => {
const data = await endpoints.books.show({id: 1})
alert(JSON.stringify(data.book))
})
```

Now the `Show the first Book` button should be working. The frontend and backend data will still be typechecked with custom types, so if you missed a field in response or missed the `id` in params, Camille and TypeScript will prompt you.

## Stage-7: Reuse Book type for create

We added a custom type `Book`, but we cannot directly use it in `create`, since we don't pass `id` when creating a book. With the help of `Omit` type in TypeScript (https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys), we can actually remove one or many fields from an object type to form a new type.

`Omit` is supported in Camille as well, but would have slightly different syntax. The `Omit` type in Camille uses `[]` to accept parameters, and the keys are an array of symbols instead of a union of string literals in TypeScript.

So we just need to change the endpoint `create` to:
```ruby
post :create do
params(
book: Omit[Book, [:id]]
)
response(Boolean)
end
```

and `npm run sync`. The previous `Create Book` button will still work. By clicking it, you should see a new book created in the database.

## Stage-8: Custom type for Book's params

In real application, probably there're more than `id` to omit. We would need to omit fields like `created_at` and `updated_at`. Then it's probably a good idea to create a custom type that contains only the required parameters for `create` and `update`.

To better demonstrate, let's add two fields to the `Book` type:

```ruby
# backend/config/camille/types/book.rb
using Camille::Syntax

class Camille::Types::Book < Camille::Type
include Camille::Types

alias_of(
id: Number,
name: String,
author: String,
retail_price: Decimal,
created_at: DateTime,
updated_at: DateTime
)
end
```

And update the `show` action to:
```ruby
def show
book = Book.find(params[:id])
render json: {
book: {
id: book.id,
name: book.name,
author: book.author,
retail_price: book.retail_price,
created_at: book.created_at,
updated_at: book.updated_at
}
}
end
```

Then we create a new type called `Book::Params` by this command:
```shell
rails g camille:type book/params
```

And make it an alias of the `Omit` type:
```ruby
# backend/config/camille/types/book/params.rb
using Camille::Syntax

class Camille::Types::Book::Params < Camille::Type
include Camille::Types

alias_of Omit[Book, [:id, :created_at, :updated_at]]
end
```

Finally, we need to update the `params` for `create` to use the new type:
```ruby
post :create do
params(
book: Book::Params
)
response(Boolean)
end
```

Now run `npm run sync` again, the `Show the first Book` button should be showing `createAt` and `updatedAt`, while `Create Book` button still works like a charm.

## Stage-9: Custom types with transformer

You might have noticed that there are two files `date_time.rb` and `decimal.rb` sitting in `config/camille/types`. They are two default types generated by Camille for you to pass Time-related and BigDecimal values. Let's have a look at one of it.

```ruby
# backend/config/camille/types/decimal.rb
using Camille::Syntax

class Camille::Types::Decimal < Camille::Type
include Camille::Types

alias_of(Number)

# transforms a BigDecimal into a Float so it fits in Number type
def transform value
if value.is_a? BigDecimal
value.to_f
else
value
end
end
end
```

This file defines a `Decimal` type as an alias of `Number`, so our intention here is to convert a `BigDecimal` into a number when we return it in JSON response.

By default, we cannot pass a `BigDecimal` object to a `Number` field because they are different types, and Camille will complain if you do. However, you can define a `transform` method that will be called before typechecking to convert a `BigDecimal` into a `Float`, which is acceptable for `Number`. Camille will first call `transform` and use the returned value for typechecking and in JSON response.

The above Float solution might not work well if you have a really big decimal that a float cannot precisely represents. In that case, you'd better pass it as a string. Let's try to redefine `Decimal` to be a string.

```ruby
# backend/config/camille/types/decimal.rb
using Camille::Syntax

class Camille::Types::Decimal < Camille::Type
include Camille::Types

alias_of(String)

# transforms a BigDecimal into a Float so it fits in Number type
def transform value
if value.is_a? BigDecimal
value.to_s
else
value
end
end
end
```

Now after `npm run sync`, our `Show the first Book` button will show a string in the `retailPrice` field. Our change works correctly.

When we go to `main.ts`, we should see a type error, since we were passing a number `27.95` to `retailPrice`, but a number no longer fits in the string field. We need to change it to `'27.95'`, then the error will be gone.

The `Create Book` button will still work, since a string is assignable to a BigDecimal field in Rails, thanks to the coercions that Rails provides.

## The End

Now you should be equiped with the knowledge to create other types and schemas for your own use, and this tutorial has come to an end.

You might be interested in the complete list of type syntax supported by Camille, like array and union types, which can be found here: https://github.com/onyxblade/camille#available-syntax-for-types.

I hope you enjoy this gem, and if you have any feedback or suggestion, don't hesitate to post it on https://github.com/onyxblade/camille/discussions. You'll be very welcome!