【個人開発】初めてgemを作って公開してみた話

こんにちは！MaTTaと申します。
これまでWEBアプリ開発の記事をいくつか書いて来ましたが、今回は少し趣を変えてオリジナルのgemを開発したのでその経験をまとめてみます。

:::note warn
初学者につき、誤りを含む場合があります。
また、当Gemは各自のOpenAI API KEYの使用を前提にしておりその分の費用が発生します。
:::

## 開発経緯
プログラミングスクールの先輩が「アプリもいいけどGem作りもいい経験になるよ」と仰っていたのが最初のキッカケでした。「gemを作る」なんて今まで思いつきもしなかったのですが、よくよく考えるとGemって何なのかしっかり理解していないようにも思い、これもいい機会だなと。

刺激をくれた先輩の記事はこちらです。手順についてはそのまま参考にさせて頂きました。

[🐾動物言語🐾（pad_character）を『gem』で作ってみた！🐈](https://qiita.com/hfslca7439/items/6fda805d95bbd5d2fecc)

https://qiita.com/hfslca7439/items/6fda805d95bbd5d2fecc/



## なにを作ったか
普段のWEBアプリ開発でOpenAI APIを使うシーンがかなり多く、毎回同じコードを使い回しコピペしているのでこの部分をgem化してみました。
プロンプトを投げたらOpenAI APIからその回答が返ってくるシンプルなものです。

#### こちらに公開しています
[openai_single_chat](https://rubygems.org/gems/openai_single_chat)

https://rubygems.org/gems/openai_single_chat/

#### 使い方はGithubに記しています
[MaTTalv001/openai_single_chat](https://github.com/MaTTalv001/openai_single_chat)

https://github.com/MaTTalv001/openai_single_chat/


## gemの正体は何なのか
Railsアプリ開発をする側としては「なんか色々助けてくれるやつ」という印象のgemですが、簡単に言えばクラスやモジュールを切り出したものであり、Ruby言語で書かれていることに違いはありません。加えて、gem同士の依存関係などの情報も記録されています。

今回作ったgem程度のシンプルな内容しかも個人開発であればアプリ内に直接記述すれば済む話ですが、ロジックが複雑なモジュールだとそうも言ってられないのでgemとしてパッケージ化し、楽に使いまわせるようにしています。

## gemの名前を考える
gemの名称は一意でないといけません。Gemfileにgem名を書くわけなので当然です。gemの配布を取りまとめているRubyGem.orgにアクセスし、検索機能を使いながら、唯一無二のgem名を考えます。gem名は通常クラス名にも使うため、後から修正するのは非常に面倒くさいです。先にして済ませるのが吉。

https://rubygems.org/

## gemのテンプレートを作成する

一応、私の開発環境
```bash
ruby -v
# ruby 3.1.6p260 (2024-05-29 revision a777087be6) [arm64-darwin23]
bundle -v
# Bundler version 2.3.27
```
なにから作ればいいのか途方に暮れそうですが、実はコマンド一つでテンプレートが作成される新設設計がRubyには施されています。
下記のコマンドを打ちます。gem名はご自身で命名したものに変えてください

```bash
bundle gem your_gem_name
```
すると、いくつか質問がくるので順次回答していきます。回答はお好みで。

<details><summary>テンプレート作成時に投げかけられる質問</summary>

testフレームワークを選べという質問。rspecと入力してEnter
```bash
Creating gem 'openai_single_chat'...
Do you want to generate tests with your gem?
Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.test`.
Enter a test framework. rspec/minitest/test-unit/(none): rspec
```
CIサービスを選べという質問。githubと入力してEnter
```bash
Do you want to set up continuous integration for your gem? Supported services:
* CircleCI:       https://circleci.com/
* GitHub Actions: https://github.com/features/actions
* GitLab CI:      https://docs.gitlab.com/ee/ci/
* Travis CI:      https://travis-ci.org/

Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.ci`.
Enter a CI service. github/travis/gitlab/circle/(none): github
```
配布ライセンスをMITにするか？という質問。yesなのでyでEnter
```bash
Do you want to license your code permissively under the MIT license?
This means that any other developer or company will be legally allowed to use your code for free as long as they admit you created it. You can read more about the MIT license at https://choosealicense.com/licenses/mit. y/(n): y
```
オープンソースプロジェクトの行動規範を遵守するかという質問。yesなのでyでEnter
```
Codes of conduct can increase contributions to your project by contributors who prefer collaborative, safe spaces. You can read more about the code of conduct at contributor-covenant.org. Having a code of conduct means agreeing to the responsibility of enforcing it, so be sure that you are prepared to do that. Be sure that your email address is specified as a contact in the generated code of conduct so that people know who to contact in case of a violation. For suggestions about how to enforce codes of conduct, see https://bit.ly/coc-enforcement. y/(n):y
```
ソフトウェア開発プロジェクトにおける変更履歴（チェンジログ）を採用するかという質問。yesなのでyでEnter（...したけど未対応）
```bash
A changelog is a file which contains a curated, chronologically ordered list of notable changes for each version of a project. To make it easier for users and contributors to see precisely what notable changes have been made between each release (or version) of the project. Whether consumers or developers, the end users of software are human beings who care about what's in the software. When the software changes, people want to know why and how. see https://keepachangelog.com y/(n): y
```
Ruby gemプロジェクトにコードリンターとフォーマッターを追加するかどうか。rubocopを入力してEnter
```bash
Do you want to add a code linter and formatter to your gem? Supported Linters:
* RuboCop:       https://rubocop.org
* Standard:      https://github.com/testdouble/standard

Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.linter`.
Enter a linter. rubocop/standard/(none): rubocop
```
</details>






対応していくと、下記の要領でファイルが自動作成されます。
これでテンプレートは準備できました。
```bash
Creating gem 'openai_single_chat'...
MIT License enabled in config
Code of conduct enabled in config
Changelog enabled in config
RuboCop enabled in config
Initializing git repo in /Users/xxxxxxxxx/Documents/repository/openai_single_chat
      create  openai_single_chat/Gemfile
      create  openai_single_chat/lib/openai_single_chat.rb
      create  openai_single_chat/lib/openai_single_chat/version.rb
      create  openai_single_chat/sig/openai_single_chat.rbs
      create  openai_single_chat/openai_single_chat.gemspec
      create  openai_single_chat/Rakefile
      create  openai_single_chat/README.md
      create  openai_single_chat/bin/console
      create  openai_single_chat/bin/setup
      create  openai_single_chat/.gitignore
      create  openai_single_chat/.rspec
      create  openai_single_chat/spec/spec_helper.rb
      create  openai_single_chat/spec/openai_single_chat_spec.rb
      create  openai_single_chat/.github/workflows/main.yml
      create  openai_single_chat/LICENSE.txt
      create  openai_single_chat/CODE_OF_CONDUCT.md
      create  openai_single_chat/CHANGELOG.md
      create  openai_single_chat/.rubocop.yml
Gem 'openai_single_chat' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html
```

## メタデータを入力する
gemのメタデータを入力します。GithbページURLやRubyのバージョン、開発者の連絡先など。これらはRubyGem.orgでも該当gemページの右側に表示されます。

下記はすでに入力してしまっていますが、デフォルトでは`TO DO~`と書かれている箇所があるのでそこを埋めていけば良いです。（ファイル名は各自異なります）


```ruby:openai_single_chat.gemspec
# frozen_string_literal: true
require_relative "lib/openai_single_chat/version"

Gem::Specification.new do |spec|
  spec.name = "openai_single_chat"
  spec.version = OpenaiSingleChat::VERSION
  spec.authors = ["xxxxxxxxxxxx"] #あなたの名前
  spec.email = ["xxxxxxxxxxxxx@gmail.com"] #あなたの連絡先

  spec.summary = %q{xxxxxxxxxxxxxxxxxx} #gemの概要
  spec.description = %q{xxxxxxxxxxxxxx} #gemの説明
  spec.homepage = "xxxxxxxxxxxxx" #gemのページ
  spec.license = "MIT"
  spec.required_ruby_version = ">= 2.6.0" #rubyのバージョン指定

  spec.metadata["allowed_push_host"] = "https://rubygems.org" #rubygems.orgでOK

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "xxxxxxxxxxxxx" #ソースコード。Githubリポジトリで可

  # Specify which files should be added to the gem when it is released.
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
  spec.files = Dir.chdir(__dir__) do
    `git ls-files -z`.split("\x0").reject do |f|
      (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
    end
  end
  spec.bindir = "exe"
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
  spec.require_paths = ["lib"]

  # Uncomment to register a new dependency of your gem
  spec.add_dependency "httparty", "~> 0.18" # 依存関係にある他gem

  # For more information and examples about making a new gem, check out our
  # guide at: https://bundler.io/guides/creating_gem.html
end
```

## バージョンを明記する
```ruby:lib/openai_single_chat/version.rb
module OpenaiSingleChat
  VERSION = "0.1.0"
end
```

## メインになるコードを書く
gem名を参考にして、モジュール名がすでに明記されているのが便利に感じるところ
依存関係にある他gemの`require`を忘れないようにします

```ruby:lib/openai_single_chat.rb
# frozen_string_literal: true

require_relative "openai_single_chat/version"
require 'httparty'
require 'json'

module OpenaiSingleChat
  class Error < StandardError; end

  class Client
    include HTTParty
    base_uri 'https://api.openai.com/v1'
    format :json

    def initialize(model = 'gpt-4o-mini')
      @model = model
      @options = {
        headers: {
          'Content-Type' => 'application/json',
          'Authorization' => "Bearer #{ENV['OPENAI_API']}"
        }
      }
    end

    def chat(message, system_message = nil)
      messages = []
      messages << { role: 'system', content: system_message } if system_message
      messages << { role: 'user', content: message }

      body = {
        model: @model,
        messages: messages
      }.to_json

      response = self.class.post('/chat/completions', body: body, headers: @options[:headers])
      
      if response.success?
        response.parsed_response['choices'][0]['message']['content']
      else
        handle_error_response(response)
      end
    end

    private

    def handle_error_response(response)
      error_message = response.parsed_response['error']['message'] rescue '不明なエラーが発生しました'
      raise Error, "OpenAI API Error: #{error_message}"
    end
  end
end
```

## ビルドする
開発環境でテストするためにgemをインストールします
```bash
gem build openai_single_chat.gemspec
gem install ./openai_single_chat-0.1.0.gem
```

:::note warn
ここから先、テスト中に不具合が見つかってコードを修正した場合は、またビルドからやり直します。
:::

## プチテストする（省略可）
一応動くのか確認するためにrubyのコンソールで簡単に確認します。

```
# 今回の場合はAPIキーとして環境変数が必要なので
export OPENAI_API=xxxxxxxxxxxxxxxxxxxxx
```

```bash
irb
```
```bash
irb(main):001:0> require 'openai_single_chat'
client = OpenaiSingleChat::Client.new
irb(main):003:0> response = client.chat("日本で一番高い山は？")
=> "日本で一番高い山は富士山（ふじさん）です。標高は3,776メートルで、静岡県と山梨県の県境に位置しています。富士山はその美し...
```

動いていそうです。


## ちゃんとテストする
人様に配布するものなのでしっかり動作検証しておきましょう。

`Gemfile`
```
gem 'webmock'
```
HTTPリクエスト自体をモック化するライブラリです。今回はAPIを叩く動作があるのでテスト時に導入しました。特に関係ない場合は不要です。

大きくいじる箇所はないかと思いますが、今回はwebmockに関連して2行追加しています。
```ruby:spec/openai_single_chat_spec.rb
require "openai_single_chat"
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)

RSpec.configure do |config|
  # Enable flags like --only-failures and --next-failure
  config.example_status_persistence_file_path = ".rspec_status"

  # Disable RSpec exposing methods globally on `Module` and `main`
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end

```

テストコード
テストはgemによって内容が全く変わるので参考まで。

```ruby:spec/openai_single_chat_spec.rb
require 'spec_helper'
require 'openai_single_chat'

RSpec.describe OpenaiSingleChat::Client do
  let(:api_key) { 'dummy_api_key' }
  let(:client) { described_class.new }

  before do
    allow(ENV).to receive(:[]).with('OPENAI_API').and_return(api_key)
  end

  describe '#chat' do
    let(:message) { "こんにちは, AI" }
    let(:response_body) do
      {
        choices: [
          {
            message: {
              content: "こんにちは、どうかしましたか"
            }
          }
        ]
      }.to_json
    end

    before do
      stub_request(:post, "https://api.openai.com/v1/chat/completions")
        .with(
          body: {
            model: "gpt-4o-mini",
            messages: [{ role: "user", content: message }]
          }.to_json,
          headers: {
            'Content-Type' => 'application/json',
            'Authorization' => "Bearer #{api_key}"
          }
        )
        .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' })
    end

    it 'returns the AI response' do
      expect(client.chat(message)).to eq("こんにちは、どうかしましたか")
    end
  end

  context 'when API returns an error' do
    let(:error_response) do
      {
        error: {
          message: "Invalid API key"
        }
      }.to_json
    end

    before do
      stub_request(:post, "https://api.openai.com/v1/chat/completions")
        .to_return(status: 401, body: error_response, headers: { 'Content-Type' => 'application/json' })
    end

    it 'raises an OpenaiSingleChat::Error' do
      expect { client.chat("こんにちは") }.to raise_error(OpenaiSingleChat::Error, "OpenAI API Error: Invalid API key")
    end
  end
end
```

テストを実行
```
bundle exec rspec
bundle exec rspec spec/openai_single_chat_spec.rb

```

```bash
xxxxxxxxx@xxxxxxxxxxxiMac openai_single_chat % bundle exec rspec

OpenaiSingleChat::Client
  #chat
    returns the AI response
  when API returns an error
    raises an OpenaiSingleChat::Error

Finished in 0.04582 seconds (files took 0.34124 seconds to load)
2 examples, 0 failures
```
テストは通りました


## gemを公開する
RubyGemsにgemを公開する際にはまずAPIキーを取得する必要があります

- RubyGems.orgのアカウントを作成します（まだ持っていない場合）
- RubyGems.orgにログインし、アカウント設定ページに移動します
- "API KEYS"セクションをクリックします
- "New API Key"をクリックして新しいAPIキーを作成します
- APIキーはメモを取っておきましょう

![スクリーンショット 2024-09-10 22.30.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1182380/a5340789-a236-9532-78f8-b0789caad353.png)


ターミナルで以下のコマンドを実行し、環境変数にAPIキーを設定します
```bash
export GEM_HOST_API_KEY=あなたのAPIキー
```
以下のコマンドでgemをプッシュします。gem名は変えてください
```bash
gem push your_gem_name
```
エラーが出なければアップ完了です。rubygems.orgを確認しましょう
![gem.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1182380/6f862795-9d8a-a08c-0638-5b8bd6bfa07e.jpeg)



## 動作確認(任意)
適当なrailsアプリ環境を構築し、本当に使えるのかを確かめます。
(環境構築は割愛)

```:Gemfile
gem 'openai_single_chat'
```
```bash
bundle install
```

`.env`にOpenAI APIキーを記述
```:.env
OPENAI_API=xxxxxxxxxxxx
```

コントローラでモジュールを呼び出し
```ruby
require 'openai_single_chat'

class ChatController < ApplicationController
  def index
    if params[:message]
      client = OpenaiSingleChat::Client.new
      @response = client.chat(params[:message])
    end
  end
end
```

ビューで動作確認
```
<h1>Chat with AI</h1>

<%= form_with url: chat_index_path, method: :get, local: true do |f| %>
  <%= f.text_field :message, placeholder: 'Enter your message' %>
  <%= f.submit 'Send' %>
<% end %>

<% if @response %>
  <h2>AI Response:</h2>
  <p><%= @response %></p>
<% end %>
```
![スクリーンショット 2024-09-10 23.55.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1182380/6d949f34-3e31-b4fc-ec52-5331d83d0904.png)

正常に動きました。

## gemの行方（おまけ）
そういえば、gemって`bundle install`した後どこに行くのか気になってので調査しました。
先に答えをいうと`../usr/local/bundle/gems`にあります。

```
# dockerでデモアプリを作ったのでコンテナ内で確認
docker compose exec web bash

root@23bca0915897:/myapp# ls ../usr/local/bundle/gems
略
openai_single_chat-0.1.0
略   
root@23bca0915897:/myapp# 
```
たくさんのgemの中に確かに`openai_single_chat-0.1.0`がありました。

さらにいうと`../usr/local/bundle/gems/openai_single_chat-0.1.0/lib/openai_single_chat.rb`にgem本体のコードがあります。`cat`で表示させてみます。

```bash
root@23bca0915897:/myapp# cat  ../usr/local/bundle/gems/openai_single_chat-0.1.0/lib/openai_single_chat.rb

require_relative "openai_single_chat/version"
require 'httparty'
require 'json'

module OpenaiSingleChat
  class Error < StandardError; end

  class Client
    include HTTParty
    base_uri 'https://api.openai.com/v1'
    format :json

以下略
```

書いたコードがまるまるインストールされていることがわかります。なんてことはない、gemだってrubyコードなんだということです。


## まとめ
gemを作るというとなんか凄いことのように思えてしまいますが、ただ作るだけであれば出来なくもないということがわかりました。もちろん普段使わせていただいているgemはこんな単純な構造ではないはずなので、あくまでも開発プロセスがわかったという程度ですが。
やってみてよかったなと思うのは、Ruby(on rails)の構造、モジュール設計、テスト、ドキュメンテーション、OSSの作法など、ちゃんと考えようと思えたことです。個人のミニアプリ開発もこれくらい真剣にやらないとなあと...


## この記事を書いた人

https://x.com/RckLVnPtRv61824