feat: add custom tool

This commit is contained in:
Shivam Mishra
2025-10-03 15:09:37 +05:30
parent ad84274245
commit ac90cb4e31
5 changed files with 287 additions and 1 deletions
@@ -0,0 +1,21 @@
class CreateCaptainCustomTools < ActiveRecord::Migration[7.1]
def change
create_table :captain_custom_tools do |t|
t.references :account, null: false, index: true
t.string :slug, null: false
t.string :title, null: false
t.text :description
t.string :http_method, null: false, default: 'GET'
t.text :endpoint_url, null: false
t.text :request_template
t.text :response_template
t.string :auth_type, default: 'none'
t.jsonb :auth_config, default: {}
t.boolean :enabled, default: true, null: false
t.timestamps
end
add_index :captain_custom_tools, [:account_id, :slug], unique: true
end
end
+19 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -323,6 +323,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
create_table "captain_custom_tools", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "slug", null: false
t.string "title", null: false
t.text "description"
t.string "http_method", default: "GET", null: false
t.text "endpoint_url", null: false
t.text "request_template"
t.text "response_template"
t.string "auth_type", default: "none"
t.jsonb "auth_config", default: {}
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true
t.index ["account_id"], name: "index_captain_custom_tools_on_account_id"
end
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false
@@ -0,0 +1,60 @@
# == Schema Information
#
# Table name: captain_custom_tools
#
# id :bigint not null, primary key
# auth_config :jsonb
# auth_type :string default("none")
# description :text
# enabled :boolean default(TRUE), not null
# endpoint_url :text not null
# http_method :string default("GET"), not null
# request_template :text
# response_template :text
# slug :string not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_custom_tools_on_account_id (account_id)
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
#
class Captain::CustomTool < ApplicationRecord
self.table_name = 'captain_custom_tools'
belongs_to :account
enum :http_method, %w[GET POST].index_by(&:itself), validate: true
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
before_validation :generate_slug
validates :slug, presence: true, uniqueness: { scope: :account_id }
validates :title, presence: true
validates :endpoint_url, presence: true
scope :enabled, -> { where(enabled: true) }
private
def generate_slug
return if slug.present?
base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}"
self.slug = find_unique_slug(base_slug)
end
def find_unique_slug(base_slug, counter = 0)
slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}"
return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate)
slug_candidate
end
def slug_exists?(candidate)
self.class.exists?(account_id: account_id, slug: candidate)
end
end
@@ -0,0 +1,146 @@
require 'rails_helper'
RSpec.describe Captain::CustomTool, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:endpoint_url) }
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
it {
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
}
describe 'slug uniqueness' do
let(:account) { create(:account) }
it 'validates uniqueness of slug scoped to account' do
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:slug]).to include('has already been taken')
end
it 'allows same slug across different accounts' do
account2 = create(:account)
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool')
expect(different_account_tool).to be_valid
end
end
end
describe 'scopes' do
let(:account) { create(:account) }
describe '.enabled' do
it 'returns only enabled custom tools' do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
end
end
end
describe 'slug generation' do
let(:account) { create(:account) }
it 'generates slug from title on creation' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
expect(tool.slug).to eq('custom_fetch-order-status')
end
it 'adds custom_ prefix to generated slug' do
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
expect(tool.slug).to start_with('custom_')
end
it 'does not override manually set slug' do
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug')
expect(tool.slug).to eq('custom_manual-slug')
end
it 'handles slug collisions by appending counter' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool2.slug).to eq('custom_test-tool-1')
end
it 'handles multiple slug collisions' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1')
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool3.slug).to eq('custom_test-tool-2')
end
it 'generates slug with UUID when title is blank' do
tool = build(:captain_custom_tool, account: account, title: nil)
tool.valid?
expect(tool.slug).to match(/^custom_[0-9a-f-]+$/)
end
it 'parameterizes title correctly' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
expect(tool.slug).to eq('custom_fetch-order-status-details')
end
end
describe 'factory' do
it 'creates a valid custom tool with default attributes' do
tool = create(:captain_custom_tool)
expect(tool).to be_valid
expect(tool.title).to be_present
expect(tool.slug).to be_present
expect(tool.endpoint_url).to be_present
expect(tool.http_method).to eq('GET')
expect(tool.auth_type).to eq('none')
expect(tool.enabled).to be true
end
it 'creates valid tool with POST trait' do
tool = create(:captain_custom_tool, :with_post)
expect(tool.http_method).to eq('POST')
expect(tool.request_template).to be_present
end
it 'creates valid tool with bearer auth trait' do
tool = create(:captain_custom_tool, :with_bearer_auth)
expect(tool.auth_type).to eq('bearer')
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
end
it 'creates valid tool with basic auth trait' do
tool = create(:captain_custom_tool, :with_basic_auth)
expect(tool.auth_type).to eq('basic')
expect(tool.auth_config['username']).to eq('test_user')
expect(tool.auth_config['password']).to eq('test_pass')
end
it 'creates valid tool with api key trait' do
tool = create(:captain_custom_tool, :with_api_key)
expect(tool.auth_type).to eq('api_key')
expect(tool.auth_config['key']).to eq('test_api_key')
expect(tool.auth_config['location']).to eq('header')
end
end
end
+41
View File
@@ -0,0 +1,41 @@
FactoryBot.define do
factory :captain_custom_tool, class: 'Captain::CustomTool' do
sequence(:title) { |n| "Custom Tool #{n}" }
description { 'A custom HTTP tool for external API integration' }
endpoint_url { 'https://api.example.com/endpoint' }
http_method { 'GET' }
auth_type { 'none' }
auth_config { {} }
enabled { true }
association :account
trait :with_post do
http_method { 'POST' }
request_template { '{ "key": "{{ value }}" }' }
end
trait :with_bearer_auth do
auth_type { 'bearer' }
auth_config { { token: 'test_bearer_token_123' } }
end
trait :with_basic_auth do
auth_type { 'basic' }
auth_config { { username: 'test_user', password: 'test_pass' } }
end
trait :with_api_key do
auth_type { 'api_key' }
auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
end
trait :with_templates do
request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' }
response_template { 'Order status: {{ response.status }}' }
end
trait :disabled do
enabled { false }
end
end
end