feat: add custom tool
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user