Generates Rails API endpoints and JavaScript API files for Webpack and more by inspecting your models and serializers.
Add this line to your application's Gemfile:
gem "api_maker", git: "https://github.com/kaspernj/api_maker.git"ApiMaker requires Shakapacker, so make sure you have that set up as well. It also uses an extension called qs, that you should add to your packages, but that is probally already there by default.
ApiMaker makes use of CanCanCan to keep track of what models a given user should have access to. Each resource defines its own abilities under app/api_maker/resources/user_resource like this:
class Resources::UserResource < Resources::ApplicationResource
def abilities
can :update, User if current_user&.admin?
can :update, User, id: current_user&.id if current_user.present?
can :read, User
end
endIf you want to use the table modules:
gem "api_maker_table", git: "https://github.com/kaspernj/api_maker.git"Run this command:
rails api_maker_table:install:migrationsRun the migrations
rails db:migrateAdd an api_maker_args method to your application controller. This controls what arguments will be passed to the CanCan ability and the serializers:
class ApplicationController
private
def api_maker_args
@api_maker_args ||= {current_user: current_user}
end
endInsert this mount into config/routes.rb:
Rails.application.routes.draw do
mount ApiMaker::Engine => "/api_maker"
endMake API maker able to listen for location changes by inserting this into your pack:
import history from "shared/history"
import {callbacksHandler} from "on-location-changed/build/callbacks-handler"
callbacksHandler.connectReactRouterHistory(history)Install the ERB loader for Webpack, and make sure it doesn't ignore the node_modules folder.
ApiMaker will only create models, endpoints and serializers for ActiveRecord models that are defined as resources. So be sure to add resources under app/api_maker/resources for your models first. You can add some helper methods if you want to use in your resources like current_user and signed_in_as_admin?.
class Resources::ApplicationResource < ApiMaker::BaseResource
def current_user
args&.dig(:current_user)
end
def signed_in_as_admin?
current_user&.role == "admin"
end
endclass Resources::UserResources < Resources::ApplicationResource
attributes :id, :email, :custom_attribute
attributes :calculated_attribute, selected_by_default: false
attributes :secret_attribute, if: :signed_in_as_admin?
collection_commands :count_users
member_commands :calculate_age
relationships :account, :tasks
def custom_attribute
"Hello world! Current user is: #{args.fetch(:current_user).email}"
end
endYou should also create an application command here: app/api_maker/commands/application_command with content like this:
class Commands::ApplicationCommand < ApiMaker::BaseCommand
endAdd this to your application model:
class ApplicationRecord < ActiveRecord::Base
include ApiMaker::ModelExtensions
endApiMaker uses that to keep track of what attributes, relationships and commands you want exposed through the API.
If you want to be able to create and update models, then you should go into each resource and create a params method to define, which attributes can be written on each model like this:
class Resources::TaskResource < ApiMaker::ModelController
def permitted_params(arg)
arg.params.require(:project).permit(:name)
end
endStart by adding i18n-on-steroids to your project:
yarn add i18n-on-steroidsCreate a I18n object you want to use throughout your project (app/javascript/i18n.js):
import I18nOnSteroids from "i18n-on-steroids"
const i18n = new I18nOnSteroids()
i18n.setLocale("en")
export default i18nYou can import it globally through the provide plugin in Webpack and then use translations like this:
i18n.t("js.some.key") //=> KeyConfigure JS routes in config/js_routes.rb:
JsRoutes.setup do |config|
config.camel_case = true
config.url_links = true
endDefine route definitions that can be read by both Rails and JS like this in app/javascript/route-definitions.json:
{
"routes": [
{"name": "new_session", "path": "/sessions/new", "component": "sessions/new"},
{"name": "root", "path": "/", "component": "sessions/new"}
]
}Define a file for js-routes in app/javascript/js-routes.js.erb that will automatically update if the routes or the definitions are changed:
/* rails-erb-loader-dependencies ../config/routes.rb ./javascript/route-definitions.json */
const routes = {};
<%= JsRoutes.generate(namespace: "Namespace") %>Install the route definitions in the Rails routes like this in config/routes.rb:
Rails.application.routes.draw do
route_definitions = JSON.parse(File.read(Rails.root.join("app/javascript/nemoa/route-definitions.json")))
ApiMaker::ResourceRouting.install_resource_routes(self, layout: "nemoa", route_definitions: route_definitions)
endDefine a routes file for your project (or multiple) in app/javascript/routes.js:
import jsRoutes from "js-routes"
import Routes from "@kaspernj/api-maker/build/routes"
import routeDefinitions from "route-definitions.json"
const routes = new Routes({jsRoutes, routeDefinitions})
export default routesYou can use your Rails routes like this:
import Routes from "routes"
Routes.userPath(user.id()) //=> /users/4Your connection.rb should look something like this:
class ApplicationCable::Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
verified_user = User.find_by(id: cookies.signed["user.id"])
if verified_user && cookies.signed["user.expires_at"] > Time.zone.now
verified_user
else
reject_unauthorized_connection
end
end
endYour channel.rb should look something like this:
class ApplicationCable::Channel < ActionCable::Channel::Base
private # rubocop:disable Layout/IndentationWidth
def current_ability
@current_ability ||= ApiMakerAbility.for_user(current_user)
end
def current_user
@current_user ||= env["warden"].user
end
endimport {Task} from "models"
const task = new Task()
task.assignAttributes({name: "New task"})
try {
await task.create()
console.log(`Task was created with ID: ${task.id()}`)
} catch (error) {
console.log("Task wasnt created")
}const task = await Task.find(5)
console.log(`Task found: ${task.name()}`)task.assignAttributes({name: "New name"})
try {
await task.save()
console.log(`Task was updated and name is now: ${task.name()}`)
} catch (error) {
console.log("Task wasnt updated")
}try {
await task.update({name: "New name"})
console.log(`Task was updated and name is now: ${task.name()}`)
} catch (error) {
console.log("Task wasnt updated")
}try {
await task.destroy()
console.log("Task was destroyed")
} catch (error) {
console.log("Task wasnt destroyed")
}const tasks = await Task.ransack().preload("project.customer").toArray()
for (const task of tasks) {
console.log(`Project of task ${task.id()}: ${task.project().name()}`)
console.log(`Customer of task ${task.id()}: ${task.project().customer().name()}`)
}API maker uses Ransack to expose a huge amount of options to query data.
const = tasks = await Task.ransack({name_cont: "something"}).toArray()
console.log(`Found: ${tasks.length} tasks`)Distinct:
const tasks = await Task.ransack({relationships_something_eq: "something"}).distinct().toArray()const tasks = await Task.ransack().select({Task: ["id", "name"]}).toArray()Task.ransack({s: "id desc"})Each attribute is defined as a method on each model. So if you have an attribute called name on the Task-model, then it be read by doing this: task.name().
You can validate model types and loaded attributes like this:
import ModelPropType from "@kaspernj/api-maker/build/model-prop-type"
class MyComponent extends React.Component {
static propTypes = {
task: ModelPropType.ofModel(Task).withLoadedAttributes(["id", "name", "updatedAt"]).isRequired
}
}Or if it isn't required:
class MyComponent extends React.Component {
static propTypes = {
task: ModelPropType.ofModel(Task).withLoadedAttributes(["id", "name", "updatedAt"]).isNotRequired
}
}You can also validate loaded abilities like this:
class MyComponent extends React.Component {
static propTypes = {
task: ModelPropType.ofModel(Task).withLoadedAbilities(["destroy", "edit"]).isNotRequired
}
}It is possible to validate on nested preloaded associations recursively as well:
class MyComponent extends React.Component {
static propTypes = {
task: ModelPropType.ofModel(Task)
.withLoadedAssociation("project")
.withLoadedAttributes(["name"]) // Validates that the attribute 'name' is loaded on the association called 'project'
.withLoadedAssociation("account")
.withLoadedAttributes(["name"]) // Validates that the attribute 'name' is loaded on the association called 'account' through 'project'
.previous()
.previous()
.isRequired
}
}A has many relationship will return a collection the queries the sub models.
const tasks = await project.tasks().toArray()
console.log(`Project ${project.id()} has ${tasks.length} tasks`)
for(const key in tasks) {
const task = tasks[key]
console.log(`Task ${task.id()} is named: ${task.name()}`)
}A belongs to relationship will return a promise that will get that model:
const project = await task.project()
console.log(`Task ${task.id()} belongs to a project called: ${project.name()}`)A has one relationship will also return a promise that will get that model like a belongs to relationship.
First include this in your layout, so JS can know which user is signed in:
<body>
<%= render "/api_maker/data" %>Then you can do like this in JS:
import Devise from "@kaspernj/api-maker/build/devise"
console.log(`The current user has this email: ${Devise.currentUser().email()}`)Add the relevant access to your resource:
class Resources::UserResource < ApplicationAbility
def abilities
can :event_new_message, User, id: 5
end
endSend an event from Ruby:
user = User.find(5)
user.api_maker_event("new_message", message: "Hello world")Receive the event in JavaScript:
const user = await User.find(5)
user.connect("new_message", args => {
console.log(`New message: ${args.message}`)
})Or you can receive the event in React:
<EventConnection event="new_message" model={user} onCall={args => this.onNewMessage(args)} />Add this to your abilities:
class ApiMakerAbility < ApplicationAbility
def initialize(args:)
can [:create_events, :destroy_events, :update_events], User, id: 5
end
endAdd this to the model you want to broadcast updates:
class User < ApplicationRecord
api_maker_broadcast_creates
api_maker_broadcast_destroys
api_maker_broadcast_updates
endconst user = await User.find(5)
let subscription = user.connectUpdated(args => {
console.log(`Model was updated: ${args.model.id()}`)
})Remember to unsubscrube again:
subscription.unsubscribe()You can also use a React component if you use React and dont want to keep track of when to unsubscribe:
import useCreatedEvent from "@kaspernj/api-maker/build/use-created-event"
import useDestroyedEvent from "@kaspernj/api-maker/build/use-destroyed-event"
import useUpdatedEvent from "@kaspernj/api-maker/build/use-updated-event"useCreatedEvent(User, this.onUserCreated)
useDestroyedEvent(user, this.onUserDestroyed)
useUpdatedEvent(user, this.onUserUpdated)onUserCreated = (args) => {
this.setState({user: args.model})
}
onUserDestroyed = (args) => {
this.setState({user: args.model})
}
onUserUpdated = (args) => {
this.setState({user: args.model})
}You can also use this React component to show a models attribute with automatic updates:
import UpdatedAttribute from "@kaspernj/api-maker/build/updated-attribute"<UpdatedAttribute model={user} attribute="email" />You can also use the EventConnection React component so you don't need to keep track of your subscription and unsubscribe:
import EventConnection from "@kaspernj/api-maker/build/event-connection"<EventConnection model={this.state.user} event="eventName" onCall={this.onEvent} />
onEvent = (data) => {
console.log("Event was called", data)
}const tasks = await Task
.ransack({name_cont: "something"})
.abilities({
Task: ["edit"]
})
.toArray()
const firstTask = tasks[0]
if (firstTask.can("edit")) {
console.log(`User can edit task ${task.id()}`)
} else {
console.log(`User cant edit task ${task.id()}`)
}Getting the CanCan instance
import { CanCan } from "api-maker"
const canCan = CanCan.current()Loading a single ability
await canCan.loadAbility("access", "admin")Loading multiple static abilities for a model
await canCan.loadAbilities([
[Invoice, ["bookBookable", "creditCreditable"]]
])To avoid doing queries for the same abilities in CanCan over an over they are cached. If some things change it can be necessary to reset those abilities.
await canCan.resetAbilities()This will only include the email for users, if the current user signed in is an admin.
class Resources::UserResource < Resources::ApplicationResource
attributes :id
attributes :email, if: :signed_in_as_admin?
private
def signed_in_as_admin?
args[:current_user]&.admin?
end
endAdd an intializer with something like this:
ApiMaker::Configuration.configure do |config|
config.on_error do |command:, controller:, error:, response:|
ExceptionNotifier.notify_exception(error, env: controller&.request&.env)
end
endYou can customise the ability object.
Configure API maker to use your own class:
ApiMaker::Configuration.configure do |config|
config.ability_class_name = "MyAbility"
endThen add an ability:
class MyAbility < ApiMaker::Ability
def initialize(args)
super
your_custom_code
end
endBundle all configurations.
bundle exec appraisal bundleRun a spec with all configurations.
bundle exec appraisal rspecIts kinda fucked up to run system specs, but this command should work from the ruby-gem directory:
rm -rf spec/dummy/public/packs/ && cd spec/dummy/ && bin/shakapacker && cd ../.. && xvfb-run bundle exec appraisal "rails 7" rspec spec/system/api_maker_table/api_maker_table_spec.rbContribution directions go here.
The gem is available as open source under the terms of the MIT License.