Service Objects in Ruby on Rails: Best Practices for Clean and Maintainable Code
Tags: ruby, services, ror, api
Published: 2023-04-19 22:07:00

Many times we need to communicate our ROR application with the outside world, either to consume a service that will provide us with information of some type or to send information ourselves.

And we can do this in two ways,
**The caveman's way**, which is simple using HTTParty and including a method to contact the API within our controller or model. This will work without any problems, it's fast, and perhaps easy to maintain if it's only your project.

**The Ruby on Rails way**, in this way, we will create a directory for services in our project called "services", inside the "app" directory, where we will put all the external services or APIs that our project consumes.

Taking advantage of the fact that it is fashionable, we are going to connect with Chat GPT.

Ok, let's get to work!

### Install HTTParty

`gem 'httparty'`

We're going to use the [HTTParty](https://rubygems.org/gems/httparty/versions/0.13.7?locale=es) gem to perform all our HTTP requests.
To install it just add the above piece of code into your `Gemfile` and then run `bundle install`.

### Service configuration

To communicate con Chat GPT we need to pass an API KEY or TOKEN in our request headers, and to keep it secure, we will save it into the Rails credentials file.
You can edit the credentials file by running this command in your terminal

`EDITOR="nano" bin/rails credentials:edit`

That will open your credentials file using "nano" as text editor.
Once you are editing it, add the following configuration line

`chatgpt_api_key: YOUR-API-KEY`


Please save and close the editor, if you're using nano, `Ctrl + x` will do the trick.

### Starting making the service

We can treat all our services as modules, see the following structure

```
➜  chatgpt_service git:(master)  tree app/services
app/services
├── chat_gpt
│   ├── data
│   │   ├── chat_completions_choice_item_data.rb
│   │   ├── completions_choice_item_data.rb
│   │   ├── models_item_data.rb
│   │   └── models_list_data.rb
│   ├── responses
│   │   ├── base_response.rb
│   │   ├── chat_completions_response.rb
│   │   ├── completions_response.rb
│   │   └── models_list_response.rb
│   └── service.rb
└── http_response.rb

4 directories, 10 files

```

The idea is to have a module to encapsulate the API we want to consume, and inside it the `Service` class with all methods to request or send information.
Inside responses we will store our classes which will be in charge to manage the information returned by the API.
In case the API returns complex data, or many objects inside each other, you could use some data classes, the ones you can see inside the `data` directory.

Don't be worried about the code, since I prepared a public Github repository with the project, take a look at it at: https://github.com/paul-ot/ror_chatgpt_client

To explain the above service structure we could say that the service will have one public method for each endpoint we will use, for example in our case we have three public methods in the service,
`completions`, `chat_completions` and `models_list`.

```ruby
module ChatGpt
  class Service
    include HTTParty
    base_uri 'https://api.openai.com/v1'

    # ...

    def completions(prompt, model = nil)
      @model = model unless model.nil?

      response = self.class.post(
        COMPLETIONS_PATH,
        body: completions_body(prompt),
        headers: common_headers,
        timeout: TIMEOUT_SECONDS
      )

      ChatGpt::Responses::CompletionsResponse.new(response)
    end

    def chat_completions(message, model = nil)
      @model = model unless model.nil?

      response = self.class.post(
        COMPLETIONS_PATH,
        body: chat_completions_body(message),
        headers: common_headers,
        timeout: TIMEOUT_SECONDS
      )

      ChatGpt::Responses::ChatCompletionsResponse.new(response)
    end

    def models_list
      response = self.class.get(
        MODELS_PATH,
        headers: common_headers,
        timeout: TIMEOUT_SECONDS
      )

      ChatGpt::Responses::ModelsListResponse.new(response)
    end

    # ...

  end
end

```

Each method will return its own response, it means that each method will return a different response, a different class, stored inside the `responses` directory, we do that on that way in order to isolate the responses, creating different methods to get the information from the JSON responses.

**Pay attention to the responses**

Completions method:

```ruby
    def completions(prompt, model = nil)
      # ...

      ChatGpt::Responses::CompletionsResponse.new(response)
    end
```

Chat completions method:

```ruby
    def chat_completions(message, model = nil)
      # ...
      
      ChatGpt::Responses::ChatCompletionsResponse.new(response)
    end
```

The models list:

```ruby
    def models_list
      # ...

      ChatGpt::Responses::ModelsListResponse.new(response)
    end
```

Here each response class inherits from a `ChatGpt::Responses::BaseResponse` class which contains the common methods across responses, the error message.

`BaseResponse`

```ruby
module ChatGpt
  module Responses
    class BaseResponse < HttpResponse
      def error_message
        response_body.dig('error', 'message')
      end
    end
  end
end
```

At the same time, `ChatGpt::Responses::BaseResponse` inherits from `HttpResponse` class, which contains the very common methods, for example the one in charge of determines whether response was ok or not, and also converts the response into JSON object.

`HttpResponse`

```ruby
class HttpResponse
  attr_reader :response

  def initialize(response)
    @response = response
  end

  def response_body
    response.body.present? ? JSON.parse(response.body) : {}
  rescue JSON::ParserError
    {}
  end

  def successful?
    response.code.to_i == Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok]
  end

  def failed?
    !successful?
  end
end

```

I did that on that way to make it more reusable, so you can have several services each with their own responses, inheriting from this `HttpResponse` class.

About the usage of this service, it is very simple

1- Create a service instance

```ruby
> service = ChatGpt::Service.new

=> #<ChatGpt::Service:0x00007fccde2ed0d0 @model="gpt-3.5-turbo">
```

2- You can try to get the GPT models list as follows

```ruby
response = service.models_list

=> #<ChatGpt::Responses::ModelsListResponse:0x00007fccdd7d6390 @response=#<HTTParty::Response:0x7fccdd6c7ad0 parsed_response={"object"=>"list", "data"=>[{"id"=>"babbage", "object"=>"model", "created"=>1649358449, "owned_by"=>"openai", "permission"=>[{"id"=>"modelperm-49FUp5v084tBB49tC4z8LPH5", ...>

> response.items.count
=> 64

> response.items.first
=> #<ChatGpt::Data::ModelsItemData:0x00007fccde9cfa60 @params={"id"=>"babbage", "object"=>"model", "created"=>1649358449, "owned_by"=>"openai", "permission"=>[{"id"=>"modelperm-49FUp5v084tBB49tC4z8LPH5", "object"=>"model_permission", "created"=>1669085501, "allow_create_engine"=>false, "allow_sampling"=>true, "allow_logprobs"=>true, "allow_search_indices"=>false, "allow_view"=>true, "allow_fine_tuning"=>false, "organization"=>"*", "group"=>nil, "is_blocking"=>false}], "root"=>"babbage", "parent"=>nil}>
```

For more examples please refer to the Github repository, it has a README.md file with them.

I hope I have contributed something useful, and I hope I have been able to explain my point of view about the use of services in Ruby on Rails.

Thanks for reading!