Skip to content

Commit b33fb84

Browse files
committed
bin/c-search: グループURLからgroup_id取得機能を追加
以下の3つの入力パターンに対応: - グループURL: https://coderdojoaoyama.connpass.com/ - イベントURL: https://coderdojoaoyama.connpass.com/event/356972/ - イベントID: 356972 主な改善点: - Connpass API v2の/groups/エンドポイントを使用してグループ検索を実装 - HTTPSのみ許可、.connpass.comドメインのみ許可(セキュリティ対策) - 適切なエラーハンドリングとタイムアウト設定(5秒) - X-Api-Keyヘッダーを使用した認証 - イベントが公開されていないグループでもgroup_idを取得可能 テストファイルも追加(TDDアプローチ)
1 parent 6bbba6e commit b33fb84

File tree

2 files changed

+354
-22
lines changed

2 files changed

+354
-22
lines changed

bin/c-search

Lines changed: 117 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,136 @@
22

33
require 'connpass_api_v2'
44
require 'uri'
5+
require 'net/http'
6+
require 'json'
57

68
if ENV['CONNPASS_API_KEY'].nil?
79
puts('CONNPASS_API_KEY が設定されていません')
810
exit(1)
911
end
1012

1113
if ARGV.empty?
12-
puts('Usage: c-search [CONNPASS_EVENT_URL | CONNPASS_EVENT_ID]')
14+
puts('Usage: c-search [CONNPASS_URL | CONNPASS_EVENT_ID]')
15+
puts(' 例: c-search https://coderdojoaoyama.connpass.com/')
16+
puts(' 例: c-search https://coderdojoaoyama.connpass.com/event/356972/')
17+
puts(' 例: c-search 356972')
1318
exit(1)
1419
end
1520

1621
input = ARGV[0]
17-
event_id = nil
18-
if input =~ /^https?:\/\//
19-
# URLからイベントIDを抽出
20-
event_id = URI(input).path[%r{event/(\d+)}, 1]
21-
else
22-
event_id = input.gsub(/\D/, '')
22+
23+
# URLのバリデーションとタイプ判定
24+
def validate_and_classify_url(input)
25+
# 数字のみの場合はイベントID
26+
return { type: :event_id, value: input.gsub(/\D/, '') } if input !~ /^https?:\/\//
27+
28+
begin
29+
uri = URI.parse(input)
30+
31+
# HTTPSのみ許可(セキュリティ対策)
32+
unless uri.scheme == 'https'
33+
return { type: :error, message: "HTTPSのURLを指定してください: #{input}" }
34+
end
35+
36+
# Connpassドメインのみ許可(SSRF対策)
37+
unless uri.host&.end_with?('.connpass.com')
38+
return { type: :error, message: "Connpass のURLを指定してください: #{input}" }
39+
end
40+
41+
# イベントURLの場合
42+
if uri.path =~ %r{/event/(\d+)/?}
43+
return { type: :event_url, event_id: $1 }
44+
end
45+
46+
# グループURLの場合
47+
if uri.path == '/' || uri.path.empty?
48+
subdomain = uri.host.split('.').first
49+
return { type: :group_url, subdomain: subdomain }
50+
end
51+
52+
return { type: :error, message: "認識できないURLパターンです: #{input}" }
53+
rescue URI::InvalidURIError => e
54+
return { type: :error, message: "無効なURLです: #{input}" }
55+
end
2356
end
2457

25-
unless event_id && !event_id.empty?
26-
puts "イベントIDが特定できませんでした: #{input}"
27-
exit 1
58+
# グループ情報を取得する関数(リダイレクト対応)
59+
def fetch_group_by_subdomain(subdomain, api_key, limit = 5)
60+
return { success: false, message: "リダイレクトが多すぎます" } if limit <= 0
61+
62+
uri = URI('https://connpass.com/api/v2/groups/')
63+
uri.query = URI.encode_www_form(subdomain: subdomain, count: 1)
64+
65+
req = Net::HTTP::Get.new(uri)
66+
req['X-Api-Key'] = api_key
67+
68+
begin
69+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true,
70+
open_timeout: 5, read_timeout: 5) do |http|
71+
http.request(req)
72+
end
73+
74+
case res
75+
when Net::HTTPSuccess
76+
data = JSON.parse(res.body)
77+
if data['results_returned'] && data['results_returned'] > 0
78+
group = data['groups'].first
79+
return { success: true, group_id: group['id'] }
80+
else
81+
return { success: false, message: "グループが見つかりませんでした (subdomain: #{subdomain})" }
82+
end
83+
when Net::HTTPRedirection # 301, 302などのリダイレクト
84+
location = res['location']
85+
if location
86+
# 新しいURIでリトライ
87+
new_uri = URI.join(uri, location)
88+
return fetch_group_by_subdomain(subdomain, api_key, limit - 1)
89+
else
90+
return { success: false, message: "リダイレクト先が不明です" }
91+
end
92+
when Net::HTTPNotFound
93+
return { success: false, message: "グループが見つかりませんでした (subdomain: #{subdomain})" }
94+
else
95+
return { success: false, message: "APIエラー: #{res.code} #{res.message}" }
96+
end
97+
rescue Timeout::Error
98+
return { success: false, message: "APIへの接続がタイムアウトしました" }
99+
rescue => e
100+
return { success: false, message: "エラーが発生しました: #{e.message}" }
101+
end
28102
end
29103

30-
client = ConnpassApiV2.client(ENV['CONNPASS_API_KEY'])
31-
result = client.get_events(event_id: event_id)
32-
33-
if result.results_returned > 0
34-
event = result.events.first
35-
puts event.fetch('group').fetch('id')
36-
#puts "id: #{event.fetch('id')}"
37-
#puts "title: #{event.fetch('title')}"
38-
#puts "group_id: #{event.fetch('group').fetch('id')}"
39-
#puts "group_name: #{event.fetch('group').fetch('title')}"
40-
else
41-
puts "イベントが見つかりませんでした (event_id: #{event_id})"
104+
# メイン処理
105+
result = validate_and_classify_url(input)
106+
107+
case result[:type]
108+
when :error
109+
puts result[:message]
110+
exit 1
111+
112+
when :event_id, :event_url
113+
# イベントIDまたはイベントURLの場合(既存の処理)
114+
event_id = result[:type] == :event_id ? result[:value] : result[:event_id]
115+
116+
client = ConnpassApiV2.client(ENV['CONNPASS_API_KEY'])
117+
api_result = client.get_events(event_id: event_id)
118+
119+
if api_result.results_returned > 0
120+
event = api_result.events.first
121+
puts event.fetch('group').fetch('id')
122+
else
123+
puts "イベントが見つかりませんでした (event_id: #{event_id})"
124+
exit 1
125+
end
126+
127+
when :group_url
128+
# グループURLの場合(新規処理)
129+
group_result = fetch_group_by_subdomain(result[:subdomain], ENV['CONNPASS_API_KEY'])
130+
131+
if group_result[:success]
132+
puts group_result[:group_id]
133+
else
134+
puts group_result[:message]
135+
exit 1
136+
end
42137
end

spec/bin/c_search_spec.rb

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
require 'spec_helper'
2+
require 'open3'
3+
require 'net/http'
4+
require 'connpass_api_v2'
5+
6+
RSpec.describe 'bin/c-search' do
7+
let(:script_path) { File.expand_path('../../bin/c-search', __dir__) }
8+
let(:api_key) { 'test_api_key_123' }
9+
10+
before do
11+
ENV['CONNPASS_API_KEY'] = api_key
12+
end
13+
14+
after do
15+
ENV.delete('CONNPASS_API_KEY')
16+
end
17+
18+
describe '使い方の表示' do
19+
context '引数なしで実行した場合' do
20+
it 'Usageメッセージを表示して終了コード1を返す' do
21+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path)
22+
23+
expect(status.exitstatus).to eq(1)
24+
expect(output).to include('Usage: c-search [CONNPASS_URL | CONNPASS_EVENT_ID]')
25+
expect(output).to include('例: c-search https://coderdojoaoyama.connpass.com/')
26+
expect(output).to include('例: c-search https://coderdojoaoyama.connpass.com/event/356972/')
27+
expect(output).to include('例: c-search 356972')
28+
end
29+
end
30+
31+
context 'CONNPASS_API_KEYが設定されていない場合' do
32+
before { ENV.delete('CONNPASS_API_KEY') }
33+
34+
it 'エラーメッセージを表示して終了コード1を返す' do
35+
output, error, status = Open3.capture3({}, "bundle", "exec", "ruby", script_path, "123456")
36+
37+
expect(status.exitstatus).to eq(1)
38+
expect(output).to include('CONNPASS_API_KEY が設定されていません')
39+
end
40+
end
41+
end
42+
43+
describe 'イベントIDでの検索(既存機能)' do
44+
context '数字のみを指定した場合' do
45+
it 'イベントAPIを呼び出してgroup_idを表示する' do
46+
# ConnpassApiV2 gemのモック
47+
mock_client = double('ConnpassApiV2::Client')
48+
mock_result = double('result',
49+
results_returned: 1,
50+
events: [{
51+
'id' => 356972,
52+
'title' => 'CoderDojo 青山',
53+
'group' => { 'id' => 1234, 'title' => 'CoderDojo 青山' }
54+
}]
55+
)
56+
57+
allow(ConnpassApiV2).to receive(:client).with(api_key).and_return(mock_client)
58+
allow(mock_client).to receive(:get_events).with(event_id: '356972').and_return(mock_result)
59+
60+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "356972")
61+
62+
if status.exitstatus != 0
63+
puts "Error output: #{error}"
64+
puts "Standard output: #{output}"
65+
end
66+
67+
expect(status.exitstatus).to eq(0)
68+
expect(output.strip).to eq('1234')
69+
end
70+
end
71+
72+
context 'イベントが見つからない場合' do
73+
it 'エラーメッセージを表示して終了コード1を返す' do
74+
mock_client = double('ConnpassApiV2::Client')
75+
mock_result = double('result', results_returned: 0, events: [])
76+
77+
allow(ConnpassApiV2).to receive(:client).with(api_key).and_return(mock_client)
78+
allow(mock_client).to receive(:get_events).with(event_id: '999999').and_return(mock_result)
79+
80+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "999999")
81+
82+
expect(status.exitstatus).to eq(1)
83+
expect(output).to include('イベントが見つかりませんでした (event_id: 999999)')
84+
end
85+
end
86+
end
87+
88+
describe 'イベントURLでの検索(既存機能)' do
89+
context 'HTTPSのイベントURLを指定した場合' do
90+
it 'URLからイベントIDを抽出してAPIを呼び出す' do
91+
mock_client = double('ConnpassApiV2::Client')
92+
mock_result = double('result',
93+
results_returned: 1,
94+
events: [{
95+
'id' => 356972,
96+
'group' => { 'id' => 1234 }
97+
}]
98+
)
99+
100+
allow(ConnpassApiV2).to receive(:client).with(api_key).and_return(mock_client)
101+
allow(mock_client).to receive(:get_events).with(event_id: '356972').and_return(mock_result)
102+
103+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://coderdojoaoyama.connpass.com/event/356972/")
104+
105+
expect(status.exitstatus).to eq(0)
106+
expect(output.strip).to eq('1234')
107+
end
108+
end
109+
end
110+
111+
describe 'グループURLでの検索(新機能)' do
112+
context 'HTTPSのグループURLを指定した場合' do
113+
it 'URLからサブドメインを抽出してグループAPIを呼び出す' do
114+
# Net::HTTPのモック
115+
mock_response = double('response',
116+
code: '200',
117+
body: {
118+
total_items: 1,
119+
groups: [{
120+
id: 1234,
121+
title: 'CoderDojo 青山',
122+
subdomain: 'coderdojoaoyama'
123+
}]
124+
}.to_json
125+
)
126+
127+
allow(Net::HTTP).to receive(:start).and_yield(double('http').tap do |http|
128+
allow(http).to receive(:request).and_return(mock_response)
129+
end)
130+
131+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://coderdojoaoyama.connpass.com/")
132+
133+
expect(status.exitstatus).to eq(0)
134+
expect(output.strip).to eq('1234')
135+
end
136+
end
137+
138+
context 'グループが見つからない場合' do
139+
it 'エラーメッセージを表示して終了コード1を返す' do
140+
mock_response = double('response',
141+
code: '200',
142+
body: { total_items: 0, groups: [] }.to_json
143+
)
144+
145+
allow(Net::HTTP).to receive(:start).and_yield(double('http').tap do |http|
146+
allow(http).to receive(:request).and_return(mock_response)
147+
end)
148+
149+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://nonexistent.connpass.com/")
150+
151+
expect(status.exitstatus).to eq(1)
152+
expect(output).to include('グループが見つかりませんでした (subdomain: nonexistent)')
153+
end
154+
end
155+
156+
context 'APIが404を返す場合' do
157+
it 'エラーメッセージを表示して終了コード1を返す' do
158+
mock_response = Net::HTTPNotFound.new('1.1', '404', 'Not Found')
159+
allow(mock_response).to receive(:body).and_return('')
160+
161+
allow(Net::HTTP).to receive(:start).and_yield(double('http').tap do |http|
162+
allow(http).to receive(:request).and_return(mock_response)
163+
end)
164+
165+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://notfound.connpass.com/")
166+
167+
expect(status.exitstatus).to eq(1)
168+
expect(output).to include('グループが見つかりませんでした (subdomain: notfound)')
169+
end
170+
end
171+
172+
context 'APIエラーが発生した場合' do
173+
it 'エラーメッセージを表示して終了コード1を返す' do
174+
mock_response = Net::HTTPInternalServerError.new('1.1', '500', 'Internal Server Error')
175+
allow(mock_response).to receive(:body).and_return('Internal Server Error')
176+
177+
allow(Net::HTTP).to receive(:start).and_yield(double('http').tap do |http|
178+
allow(http).to receive(:request).and_return(mock_response)
179+
end)
180+
181+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://error.connpass.com/")
182+
183+
expect(status.exitstatus).to eq(1)
184+
expect(output).to include('APIエラー: 500')
185+
end
186+
end
187+
188+
context 'タイムアウトが発生した場合' do
189+
it 'タイムアウトメッセージを表示して終了コード1を返す' do
190+
allow(Net::HTTP).to receive(:start).and_raise(Timeout::Error.new('execution expired'))
191+
192+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://timeout.connpass.com/")
193+
194+
expect(status.exitstatus).to eq(1)
195+
expect(output).to include('APIへの接続がタイムアウトしました')
196+
end
197+
end
198+
end
199+
200+
describe 'セキュリティバリデーション' do
201+
context 'HTTPのURLを指定した場合' do
202+
it 'HTTPSを要求するエラーメッセージを表示する' do
203+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "http://coderdojoaoyama.connpass.com/")
204+
205+
expect(status.exitstatus).to eq(1)
206+
expect(output).to include('HTTPSのURLを指定してください')
207+
end
208+
end
209+
210+
context 'Connpass以外のドメインを指定した場合' do
211+
it 'Connpassドメインを要求するエラーメッセージを表示する' do
212+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://example.com/")
213+
214+
expect(status.exitstatus).to eq(1)
215+
expect(output).to include('Connpass のURLを指定してください')
216+
end
217+
end
218+
219+
context '無効なURLを指定した場合' do
220+
it '無効なURLエラーメッセージを表示する' do
221+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://[invalid")
222+
223+
expect(status.exitstatus).to eq(1)
224+
expect(output).to include('無効なURLです')
225+
end
226+
end
227+
228+
context '認識できないURLパターンの場合' do
229+
it '認識できないパターンエラーを表示する' do
230+
output, error, status = Open3.capture3({"CONNPASS_API_KEY" => api_key}, "bundle", "exec", "ruby", script_path, "https://coderdojoaoyama.connpass.com/about/")
231+
232+
expect(status.exitstatus).to eq(1)
233+
expect(output).to include('認識できないURLパターンです')
234+
end
235+
end
236+
end
237+
end

0 commit comments

Comments
 (0)