From 24bbf9f16cf3fd9b1147c32f32b5ac4db9d385c9 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 3 Oct 2025 17:37:24 +0530 Subject: [PATCH] feat: add controller for custom tools --- config/routes.rb | 1 + .../captain/custom_tools_controller.rb | 49 +++ .../concerns/safe_endpoint_validatable.rb | 5 +- .../app/models/enterprise/concerns/account.rb | 1 + .../policies/captain/custom_tool_policy.rb | 21 ++ .../captain/custom_tools/create.json.jbuilder | 1 + .../captain/custom_tools/index.json.jbuilder | 10 + .../captain/custom_tools/show.json.jbuilder | 1 + .../captain/custom_tools/update.json.jbuilder | 1 + .../models/captain/_custom_tool.json.jbuilder | 15 + .../captain/custom_tools_controller_spec.rb | 281 ++++++++++++++++++ 11 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb create mode 100644 enterprise/app/policies/captain/custom_tool_policy.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb diff --git a/config/routes.rb b/config/routes.rb index bf455949c..757d20620 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,7 @@ Rails.application.routes.draw do resources :copilot_threads, only: [:index, :create] do resources :copilot_messages, only: [:index, :create] end + resources :custom_tools resources :documents, only: [:index, :show, :create, :destroy] end resource :saml_settings, only: [:show, :create, :update, :destroy] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb new file mode 100644 index 000000000..3137ded09 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::CustomTool) } + before_action :set_custom_tool, only: [:show, :update, :destroy] + + def index + @custom_tools = account_custom_tools.enabled + end + + def show; end + + def create + @custom_tool = account_custom_tools.create!(custom_tool_params) + end + + def update + @custom_tool.update!(custom_tool_params) + end + + def destroy + @custom_tool.destroy + head :no_content + end + + private + + def set_custom_tool + @custom_tool = account_custom_tools.find(params[:id]) + end + + def account_custom_tools + @account_custom_tools ||= Current.account.captain_custom_tools + end + + def custom_tool_params + params.require(:custom_tool).permit( + :title, + :description, + :endpoint_url, + :http_method, + :request_template, + :response_template, + :auth_type, + :enabled, + auth_config: {}, + param_schema: [:name, :type, :description, :required] + ) + end +end diff --git a/enterprise/app/models/concerns/safe_endpoint_validatable.rb b/enterprise/app/models/concerns/safe_endpoint_validatable.rb index 53c2cfeb7..b151b10e7 100644 --- a/enterprise/app/models/concerns/safe_endpoint_validatable.rb +++ b/enterprise/app/models/concerns/safe_endpoint_validatable.rb @@ -23,7 +23,10 @@ module Concerns::SafeEndpointValidatable end def parse_endpoint_uri - URI.parse(endpoint_url) + # Strip Liquid template syntax for validation + # Replace {{ variable }} with a placeholder value + sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder') + URI.parse(sanitized_url) rescue URI::InvalidURIError nil end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index b52ac4b3e..091b5697a 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -9,6 +9,7 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' + has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :copilot_threads, dependent: :destroy_async diff --git a/enterprise/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb new file mode 100644 index 000000000..b88a23860 --- /dev/null +++ b/enterprise/app/policies/captain/custom_tool_policy.rb @@ -0,0 +1,21 @@ +class Captain::CustomToolPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder new file mode 100644 index 000000000..c57a92261 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder @@ -0,0 +1,10 @@ +json.payload do + json.array! @custom_tools do |custom_tool| + json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool + end +end + +json.meta do + json.total_count @custom_tools.count + json.page 1 +end diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder new file mode 100644 index 000000000..bc1edc913 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder @@ -0,0 +1,15 @@ +json.id custom_tool.id +json.slug custom_tool.slug +json.title custom_tool.title +json.description custom_tool.description +json.endpoint_url custom_tool.endpoint_url +json.http_method custom_tool.http_method +json.request_template custom_tool.request_template +json.response_template custom_tool.response_template +json.auth_type custom_tool.auth_type +json.auth_config custom_tool.auth_config +json.param_schema custom_tool.param_schema +json.enabled custom_tool.enabled +json.account_id custom_tool.account_id +json.created_at custom_tool.created_at +json.updated_at custom_tool.updated_at diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb new file mode 100644 index 000000000..71d08ae15 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb @@ -0,0 +1,281 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status' do + create_list(:captain_custom_tool, 3, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(3) + end + end + + context 'when it is an admin' do + it 'returns success status and custom tools' do + create_list(:captain_custom_tool, 5, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(5) + end + + it 'returns only enabled custom tools' do + create(:captain_custom_tool, account: account, enabled: true) + create(:captain_custom_tool, account: account, enabled: false) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload].first[:enabled]).to be(true) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status and custom tool' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:id]).to eq(custom_tool.id) + expect(json_response[:title]).to eq(custom_tool.title) + end + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do + let(:valid_attributes) do + { + custom_tool: { + title: 'Fetch Order Status', + description: 'Fetches order status from external API', + endpoint_url: 'https://api.example.com/orders/{{ order_id }}', + http_method: 'GET', + enabled: true, + param_schema: [ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ] + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'creates a new custom tool and returns success status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Fetch Order Status') + expect(json_response[:description]).to eq('Fetches order status from external API') + expect(json_response[:enabled]).to be(true) + expect(json_response[:slug]).to eq('custom_fetch-order-status') + expect(json_response[:param_schema]).to eq([ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ]) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '', + endpoint_url: '' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'with invalid endpoint URL' do + let(:invalid_url_attributes) do + { + custom_tool: { + title: 'Test Tool', + endpoint_url: 'http://localhost/api', + http_method: 'GET' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_url_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + let(:update_attributes) do + { + custom_tool: { + title: 'Updated Tool Title', + enabled: false + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'updates the custom tool and returns success status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Updated Tool Title') + expect(json_response[:enabled]).to be(false) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '' + } + } + end + + it 'returns unprocessable entity status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let!(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'deletes the custom tool and returns no content status' do + expect do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: admin.create_new_auth_token + end.to change(Captain::CustomTool, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + end +end