diff --git a/CHANGELOG.md b/CHANGELOG.md index a39f111..bc1243f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.3.0 (Next) * [#183](https://github.com/slack-ruby/slack-ruby-bot-server/pull/183): Add a `config.server_pages` block to configure page middleware options centrally without changing the installed library's defaults - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). +* [#184](https://github.com/slack-ruby/slack-ruby-bot-server/pull/184): Add a configurable API application and separately mountable team read/registration endpoints, preserving the default API while supporting custom routes and registration-only applications - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). * Your contribution here. ### 2.2.1 (2026/10/24) diff --git a/README.md b/README.md index a66f76f..45d4f77 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Build a complete Slack bot service with Slack button integration, in Ruby. - [OAuth Version and Scopes](#oauth-version-and-scopes) - [Slack App](#slack-app) - [API](#api) + - [Custom Endpoints](#custom-endpoints) + - [Team Endpoint Visibility](#team-endpoint-visibility) - [App](#app) - [Service Manager](#service-manager) - [Lifecycle Callbacks](#lifecycle-callbacks) @@ -152,6 +154,58 @@ Navigate to either [localhost:9292](http://localhost:9292) or the ngrok URL abov This library implements an app, [SlackRubyBotServer::App](lib/slack-ruby-bot-server/app.rb) and a service manager, [SlackRubyBotServer::Service](lib/slack-ruby-bot-server/service.rb). It also provides [default HTML templates and JS scripts](public) for Slack integration. +#### Custom Endpoints + +Set `config.api` to a Rack application, typically a `Grape::API` class, to customize the API without reopening the framework's classes. The default is `nil`, which uses `SlackRubyBotServer::Api::Endpoints::RootEndpoint`, including extensions mounted there by other gems. `SlackRubyBotServer::Config.reset!` restores this default. + +To add an endpoint alongside the default API: + +```ruby +class ExtraApi < Grape::API + prefix 'api' + format :json + + get :foobar do + { ok: true } + end +end + +class ApplicationApi < Grape::API + mount SlackRubyBotServer::Api::Endpoints::RootEndpoint + mount ExtraApi +end + +SlackRubyBotServer.configure do |config| + config.api = ApplicationApi +end +``` + +Configure this before starting the application. In `config.ru`, continue running `SlackRubyBotServer::Api::Middleware.instance`; both `/api/foobar` and the existing `/api` routes then share the framework's CORS, URL-rewrite, and page middleware. A separate `Rack::Cascade` stack is unnecessary. + +In Rails, require the file defining `ApplicationApi` from your initializer before assigning `config.api`. For reloadable classes, make the assignment inside `Rails.application.config.to_prepare` so it references the current class. Mount `SlackRubyBotServer::Api::Middleware.instance` in your routes, for example `mount SlackRubyBotServer::Api::Middleware.instance => '/'`, rather than bypassing that middleware stack. + +#### Team Endpoint Visibility + +The default API exposes `GET /api/teams` and `GET /api/teams/:id` publicly. To retain OAuth registration without those read endpoints, use the separately mountable `TeamsEndpoint::Create`. `TeamsEndpoint::Read` contains the list and detail routes; `TeamsEndpoint` continues to mount both for backward compatibility. + +```ruby +class RegistrationApi < Grape::API + include SlackRubyBotServer::Api::Helpers::ErrorHelpers + + prefix 'api' + format :json + formatter :json, Grape::Formatter::Roar + + mount SlackRubyBotServer::Api::Endpoints::TeamsEndpoint::Create +end + +SlackRubyBotServer.configure do |config| + config.api = RegistrationApi +end +``` + +This replaces the default root API: it does not expose its status, root discovery, Swagger, or team-read routes. Do not also mount `RootEndpoint` or `TeamsEndpoint`, since either would restore the public team reads. If you use the Events extension or other custom endpoints, explicitly mount their routes with their required signature verification too. Add your own authentication before mounting `TeamsEndpoint::Read` if you need protected team queries. OAuth registration still requires a valid Slack authorization code; this configuration does not add a general authentication layer. + #### App The app instance checks for a working database connection, ensures indexes, performs migrations, sets up bot aliases and log levels. You can introduce custom behavior into the app lifecycle by subclassing `SlackRubyBotServer::App` and creating an instance of the child class in `config.ru`. diff --git a/lib/slack-ruby-bot-server/api/endpoints/teams_endpoint.rb b/lib/slack-ruby-bot-server/api/endpoints/teams_endpoint.rb index 0514dba..972737c 100644 --- a/lib/slack-ruby-bot-server/api/endpoints/teams_endpoint.rb +++ b/lib/slack-ruby-bot-server/api/endpoints/teams_endpoint.rb @@ -2,118 +2,129 @@ module SlackRubyBotServer module Api module Endpoints class TeamsEndpoint < Grape::API - format :json - helpers Helpers::CursorHelpers - helpers Helpers::SortHelpers - helpers Helpers::PaginationParameters - - namespace :teams do - desc 'Get a team.' - params do - requires :id, type: String, desc: 'Team ID.' - end - get ':id' do - team = Team.find(params[:id]) || error!('Not Found', 404) - present team, with: Presenters::TeamPresenter - end + class Read < Grape::API + format :json + helpers Helpers::CursorHelpers + helpers Helpers::SortHelpers + helpers Helpers::PaginationParameters - desc 'Get all the teams.' - params do - optional :active, type: ::Grape::API::Boolean, desc: 'Return active teams only.' - use :pagination - end - sort Team::SORT_ORDERS - get do - teams = Team.all - teams = teams.active if params[:active] - teams = paginate_and_sort_by_cursor(teams, default_sort_order: '-id') - present teams, with: Presenters::TeamsPresenter - end + namespace :teams do + desc 'Get a team.' + params do + requires :id, type: String, desc: 'Team ID.' + end + get ':id' do + team = Team.find(params[:id]) || error!('Not Found', 404) + present team, with: Presenters::TeamPresenter + end - desc 'Create a team using an OAuth token.' - params do - requires :code, type: String - optional :state, type: String - end - post do - client = Slack::Web::Client.new - - raise 'Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET.' unless ENV.key?('SLACK_CLIENT_ID') && ENV.key?('SLACK_CLIENT_SECRET') - - options = { - client_id: ENV.fetch('SLACK_CLIENT_ID', nil), - client_secret: ENV.fetch('SLACK_CLIENT_SECRET', nil), - code: params[:code] - } - - rc = client.send(SlackRubyBotServer.config.oauth_access_method, options) - - token = nil - access_token = nil - user_id = nil - bot_user_id = nil - team_id = nil - team_name = nil - oauth_scope = nil - oauth_version = SlackRubyBotServer::Config.oauth_version - - case oauth_version - when :v2 - access_token = rc.access_token - token = rc.access_token - user_id = rc.authed_user&.id - bot_user_id = rc.bot_user_id - team_id = rc.team&.id - team_name = rc.team&.name - oauth_scope = rc.scope - when :v1 - access_token = rc.access_token - bot = rc.bot if rc.key?(:bot) - token = bot ? bot.bot_access_token : access_token - user_id = rc.user_id - bot_user_id = bot ? bot.bot_user_id : nil - team_id = rc.team_id - team_name = rc.team_name - oauth_scope = rc.scope + desc 'Get all the teams.' + params do + optional :active, type: ::Grape::API::Boolean, desc: 'Return active teams only.' + use :pagination + end + sort Team::SORT_ORDERS + get do + teams = Team.all + teams = teams.active if params[:active] + teams = paginate_and_sort_by_cursor(teams, default_sort_order: '-id') + present teams, with: Presenters::TeamsPresenter end + end + end + + class Create < Grape::API + format :json - team = Team.where(token: token).first - team ||= Team.where(team_id: team_id, oauth_version: oauth_version).first - team ||= Team.where(team_id: team_id).first - - if team - team.ping_if_active! - - team.update!( - oauth_version: oauth_version, - oauth_scope: oauth_scope, - activated_user_id: user_id, - activated_user_access_token: access_token, - bot_user_id: bot_user_id - ) - - raise "Team #{team.name} is already registered." if team.active? - - team.activate!(token) - else - team = Team.create!( - token: token, - oauth_version: oauth_version, - oauth_scope: oauth_scope, - team_id: team_id, - name: team_name, - activated_user_id: user_id, - activated_user_access_token: access_token, - bot_user_id: bot_user_id - ) + namespace :teams do + desc 'Create a team using an OAuth token.' + params do + requires :code, type: String + optional :state, type: String end + post do + client = Slack::Web::Client.new + + raise 'Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET.' unless ENV.key?('SLACK_CLIENT_ID') && ENV.key?('SLACK_CLIENT_SECRET') - options = params.slice(:state) + options = { + client_id: ENV.fetch('SLACK_CLIENT_ID', nil), + client_secret: ENV.fetch('SLACK_CLIENT_SECRET', nil), + code: params[:code] + } - Service.instance.create!(team, options) - present team, with: Presenters::TeamPresenter + rc = client.send(SlackRubyBotServer.config.oauth_access_method, options) + + token = nil + access_token = nil + user_id = nil + bot_user_id = nil + team_id = nil + team_name = nil + oauth_scope = nil + oauth_version = SlackRubyBotServer::Config.oauth_version + + case oauth_version + when :v2 + access_token = rc.access_token + token = rc.access_token + user_id = rc.authed_user&.id + bot_user_id = rc.bot_user_id + team_id = rc.team&.id + team_name = rc.team&.name + oauth_scope = rc.scope + when :v1 + access_token = rc.access_token + bot = rc.bot if rc.key?(:bot) + token = bot ? bot.bot_access_token : access_token + user_id = rc.user_id + bot_user_id = bot ? bot.bot_user_id : nil + team_id = rc.team_id + team_name = rc.team_name + oauth_scope = rc.scope + end + + team = Team.where(token: token).first + team ||= Team.where(team_id: team_id, oauth_version: oauth_version).first + team ||= Team.where(team_id: team_id).first + + if team + team.ping_if_active! + + team.update!( + oauth_version: oauth_version, + oauth_scope: oauth_scope, + activated_user_id: user_id, + activated_user_access_token: access_token, + bot_user_id: bot_user_id + ) + + raise "Team #{team.name} is already registered." if team.active? + + team.activate!(token) + else + team = Team.create!( + token: token, + oauth_version: oauth_version, + oauth_scope: oauth_scope, + team_id: team_id, + name: team_name, + activated_user_id: user_id, + activated_user_access_token: access_token, + bot_user_id: bot_user_id + ) + end + + options = params.slice(:state) + + Service.instance.create!(team, options) + present team, with: Presenters::TeamPresenter + end end end + + mount Read + mount Create end end end diff --git a/lib/slack-ruby-bot-server/api/middleware.rb b/lib/slack-ruby-bot-server/api/middleware.rb index 6808eed..1358c4e 100644 --- a/lib/slack-ruby-bot-server/api/middleware.rb +++ b/lib/slack-ruby-bot-server/api/middleware.rb @@ -38,7 +38,7 @@ def self.instance end def call(env) - Endpoints::RootEndpoint.call(env) + (SlackRubyBotServer::Config.api || Endpoints::RootEndpoint).call(env) end end end diff --git a/lib/slack-ruby-bot-server/config.rb b/lib/slack-ruby-bot-server/config.rb index 9ee1599..672e592 100644 --- a/lib/slack-ruby-bot-server/config.rb +++ b/lib/slack-ruby-bot-server/config.rb @@ -8,6 +8,7 @@ module Config attr_accessor :view_paths attr_accessor :oauth_scope attr_accessor :oauth_version + attr_accessor :api def reset! self.logger = nil @@ -15,6 +16,7 @@ def reset! self.oauth_scope = nil self.oauth_version = :v2 @server_pages = nil + self.api = nil self.view_paths = [ 'views', diff --git a/spec/api/endpoints/composition_spec.rb b/spec/api/endpoints/composition_spec.rb new file mode 100644 index 0000000..9219e55 --- /dev/null +++ b/spec/api/endpoints/composition_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe 'API composition' do + include Rack::Test::Methods + + let(:app) { SlackRubyBotServer::Api::Middleware.instance } + + it 'adds custom routes alongside the default API in the shared middleware stack' do + extra = Class.new(Grape::API) do + prefix 'api' + format :json + get(:foobar) { { ok: true } } + end + custom = Class.new(Grape::API) do + mount SlackRubyBotServer::Api::Endpoints::RootEndpoint + mount extra + end + SlackRubyBotServer.config.api = custom + + get '/api/foobar', {}, 'HTTP_ORIGIN' => 'https://example.org' + expect(JSON.parse(last_response.body)).to eq('ok' => true) + expect(last_response.headers['Access-Control-Allow-Origin']).to eq '*' + get '/api' + expect(last_response.status).to eq 200 + end + + it 'restores the default API when configuration is reset' do + SlackRubyBotServer.config.api = ->(_env) { [418, {}, ['Custom API']] } + get '/api' + expect(last_response.status).to eq 418 + SlackRubyBotServer.config.reset! + get '/api' + expect(last_response.status).to eq 200 + end + + context 'with only registration mounted' do + before do + SlackRubyBotServer.config.api = Class.new(Grape::API) do + include SlackRubyBotServer::Api::Helpers::ErrorHelpers + + prefix 'api' + format :json + formatter :json, Grape::Formatter::Roar + mount SlackRubyBotServer::Api::Endpoints::TeamsEndpoint::Create + end + end + + it 'does not expose the team list or individual records' do + team = Fabricate(:team) + get '/api/teams' + expect(last_response.status).to eq 400 + expect(JSON.parse(last_response.body)['message']).to eq '405 Not Allowed' + get "/api/teams/#{team.id}" + expect(last_response.status).to eq 404 + expect(last_response.body).not_to include(team.token) + end + + it 'keeps OAuth registration available' do + oauth = Slack::Messages::Message.new( + 'access_token' => 'registration-only-token', + 'scope' => 'chat:write', + 'team' => { 'id' => 'TREGISTRATION', 'name' => 'Registration only' } + ) + allow(ENV).to receive(:key?).and_call_original + allow(ENV).to receive(:key?).with('SLACK_CLIENT_ID').and_return(true) + allow(ENV).to receive(:key?).with('SLACK_CLIENT_SECRET').and_return(true) + allow_any_instance_of(Slack::Web::Client).to receive(:oauth_v2_access).and_return(oauth) + + expect { post '/api/teams', code: 'authorization-code' }.to change(Team, :count).by(1) + expect(last_response.status).to eq 201 + expect(Team.find_by(team_id: 'TREGISTRATION').token).to eq 'registration-only-token' + end + end +end