sofia 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/CLAUDE.md +53 -0
  4. data/README.md +67 -1
  5. data/lib/sofia/adapter/net_http.rb +28 -14
  6. data/lib/sofia/adapter/soren.rb +101 -0
  7. data/lib/sofia/adapter.rb +1 -0
  8. data/lib/sofia/client.rb +9 -4
  9. data/lib/sofia/defaults/timeouts.rb +12 -0
  10. data/lib/sofia/{helper.rb → defaults.rb} +2 -3
  11. data/lib/sofia/error/{invalid_json.rb → parser_error.rb} +1 -1
  12. data/lib/sofia/error.rb +1 -1
  13. data/lib/sofia/options.rb +29 -0
  14. data/lib/sofia/request.rb +18 -14
  15. data/lib/sofia/response.rb +3 -3
  16. data/lib/sofia/types/client/adapter.rb +43 -0
  17. data/lib/sofia/types/client/base_url.rb +35 -0
  18. data/lib/sofia/types/client/body.rb +62 -0
  19. data/lib/sofia/types/client/headers.rb +58 -0
  20. data/lib/sofia/types/client/options.rb +33 -0
  21. data/lib/sofia/types/client/params.rb +57 -0
  22. data/lib/sofia/types/client/path.rb +30 -0
  23. data/lib/sofia/types/client.rb +16 -0
  24. data/lib/sofia/types/options/timeout/base.rb +34 -0
  25. data/lib/sofia/types/options/timeout/connection.rb +12 -0
  26. data/lib/sofia/types/options/timeout/read.rb +12 -0
  27. data/lib/sofia/types/options/timeout/write.rb +12 -0
  28. data/lib/sofia/types/options/timeout.rb +15 -0
  29. data/lib/sofia/types/options.rb +10 -0
  30. data/lib/sofia/types.rb +2 -6
  31. data/lib/sofia/version.rb +1 -1
  32. data/lib/sofia.rb +7 -5
  33. data/sorbet/rbi/gems/soren@0.1.2.rbi +934 -0
  34. metadata +38 -11
  35. data/lib/sofia/types/adapter.rb +0 -39
  36. data/lib/sofia/types/base_url.rb +0 -33
  37. data/lib/sofia/types/body.rb +0 -60
  38. data/lib/sofia/types/headers.rb +0 -56
  39. data/lib/sofia/types/params.rb +0 -56
  40. data/lib/sofia/types/path.rb +0 -28
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a0700857a43b841018595dea20d578f1f77a59f3c5dc625836ac44faca893e7
4
- data.tar.gz: 5641b7640ee6e1225b920ad683c3f3172a394b755b23740d5600659d15128371
3
+ metadata.gz: 720c61ca3d6708e000cde2eab720a283199d02ae5c02e45c9c513da7493c0366
4
+ data.tar.gz: 14e66efc3be75d6ec3e12d638b0df237e474212a52930bcb64dd7126b9ecf62c
5
5
  SHA512:
6
- metadata.gz: 2ce0f5a2fc2cd5a0b06107465723399f650c1ec4cfb4a311b5a8196a4f2ca10eeffd73362e4c6bd0ac1c0a97fd29dbef173f765488e620ee4ceb33c5343e063f
7
- data.tar.gz: 8f062bd2075032d046892799ae82ad336dccbee9dab7227c5b0150469fd69e67edf24b7243e764cff3127e69818f79ed0281c573589fe9e81bc93df40ba749b6
6
+ metadata.gz: 7c635e0eef70a141af112b4955e031103c1e06d09afefb6ff55a91026f0bfdcc09256d35bf0cb92819cb48edc02d5c5e58620419b0f681d4287c4367d601a17a
7
+ data.tar.gz: a8c3cd787e0c7375f55671454f1f577bbdaf25fa3d1ce227940779fc76ba36be2f516b758be7f6dd91e9569a545697293e542ab571e0443731f934b4b32931a9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.2] - 2026-04-17
4
+
5
+ ### Added
6
+
7
+ - Soren adapter (`adapter: :soren`) — a second pluggable HTTP backend backed by the [soren](https://github.com/KubaJadrzak/soren) gem.
8
+
9
+ ### Fixed
10
+
11
+ - `Sofia::Response#headers` now returns `Hash[String, Array[String]]` instead of `Hash[String, String]`, correctly preserving multi-value headers such as `Set-Cookie`.
12
+
3
13
  ## [0.1.0] - 2025-09-25
4
14
 
5
15
  - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,53 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ bin/setup # Install dependencies
9
+ rake test # Run all tests
10
+ rake rubocop # Lint with RuboCop
11
+ bundle exec srb tc # Sorbet type checking (run after every change)
12
+ bin/console # IRB prompt with gem loaded
13
+ ```
14
+
15
+ Run a single test file:
16
+ ```bash
17
+ bundle exec ruby -Ilib:test test/sofia/client_test.rb
18
+ ```
19
+
20
+ ## Architecture
21
+
22
+ Sofia is a Ruby gem providing a lightweight HTTP client abstraction with pluggable adapters (similar to Faraday). Ruby >= 3.1 required. Uses Sorbet for static type checking (`# typed: strict` throughout).
23
+
24
+ **Request lifecycle:**
25
+ 1. `Sofia.new(base_url:, adapter:)` → `Sofia::Client`
26
+ 2. User calls `.get`, `.post`, etc. with a block that configures a `Request`
27
+ 3. Client delegates to the adapter's `#call(request:)` method
28
+ 4. Adapter returns a `Sofia::Response`
29
+
30
+ **Key classes:**
31
+ - `Sofia::Client` — stores base URL + adapter; exposes HTTP verb methods
32
+ - `Sofia::Request` — mutable DSL object; builds full URL from base + path + params
33
+ - `Sofia::Response` — immutable result; `#body` parses JSON, `#success?` checks 2xx
34
+ - `Sofia::Adapter::Base` — abstract; subclasses implement `#call(request:) → Response`
35
+ - `Sofia::Adapter::NetHTTP` — only current adapter; uses stdlib `Net::HTTP` with SSL auto-detection
36
+
37
+ **Type wrappers** (`lib/sofia/types/`) validate and normalize all inputs at construction time (fail-fast). `BaseUrl` auto-prepends `https://`; `Path` ensures leading `/`; `Headers` defaults to JSON content/accept types; `Body` strips nil values recursively.
38
+
39
+ **Error hierarchy** all inherit from `Sofia::Error::Base < StandardError`: `ArgumentError`, `ConnectionFailed`, `SSLError`, `TimeoutError`, `ParserError`.
40
+
41
+ ## Adding a New Adapter
42
+
43
+ 1. Create `lib/sofia/adapter/your_adapter.rb` subclassing `Sofia::Adapter::Base`
44
+ 2. Implement `sig { override.params(request: Sofia::Request).returns(Sofia::Response) }` on `#call`
45
+ 3. Register the adapter symbol in `lib/sofia/types/adapter.rb`
46
+
47
+ ## Testing Notes
48
+
49
+ Tests mix real HTTP calls (against httpbin.org) in `test/sofia/client_test.rb` with unit tests for type wrappers. FactoryBot factories live in `test/factories/`.
50
+
51
+ ## RuboCop
52
+
53
+ Config at `.rubocop.yml` uses `EnabledByDefault: true` (strict). Key limits: method 40 lines, class 200 lines, cyclomatic complexity 12. Trailing commas required in multiline literals.
data/README.md CHANGED
@@ -1,6 +1,72 @@
1
1
  # Sofia
2
2
 
3
- This is a personal project created for self-learning purposes. The goal is to create a simplified version of Faraday gem. For now, Sofia will support only NetHTTP as adapter, but I have plans to write my own adapter in the future. Initially wanted to name this project alice but there is already an alice gem :(
3
+ This is a personal project created for self-learning purposes. The goal is to create a simple HTTP client abstraction layer, similar to `Faraday`. At the current moment `Sofia` supports only `NetHTTP` as adapter, and only with `Content-Type: JSON` and default configuration. While basic, at the current moment `Sofia` is implemented into my other project [Shopik](https://github.com/KubaJadrzak/Shopik) and allows to perform HTTP requests correctly. I will add more functionality with time. The goal is to ultimately create my own HTTP client as well.
4
+
5
+ # How it works
6
+
7
+ In order to perform a request with `Sofia` you need to initialize an instance of `client` class by providing `base_url` and `adapter`. At the current moment the only supported adapter is `NetHTTP` and it will be used by default.
8
+ ```rb
9
+ @client = Sofia.new(base_url: base_url, adapter: adapter)
10
+ ```
11
+
12
+ You can perform a request via method `send` on the instance of `client` class. You need to provide http request type (for now only `get`, `post`, `put`, `patch`, `delete` are supported) as method argument as well as block of code with configuration, for example:
13
+ ```rb
14
+ response = @client.send(method) do |req|
15
+ req.path = path
16
+ req.headers['Accept'] = 'application/json'
17
+ req.headers['Authorization'] = "Basic #{encoded_credentials}"
18
+ req.body = body if body
19
+ end
20
+ ```
21
+ It is a good practice to rescue errors which can be thrown by `Sofia`. The response codes `400-499` and `500-599` are not errors, instead you need to handle these on your own. This is an example of the entire flow that allows you to make a request based on my [Shopik](https://github.com/KubaJadrzak/Shopik) project where `Sofia` is implement as HTTP client abstraction layer:
22
+ ```rb
23
+ class EspagoClient
24
+
25
+ def initialize
26
+ base_url = ENV.fetch('ESPAGO_BASE_URL')
27
+ @user = Rails.application.credentials.dig(:espago, :app_id)
28
+ @password = Rails.application.credentials.dig(:espago, :password)
29
+
30
+ @client = Sofia.new(base_url: base_url)
31
+ end
32
+
33
+ def send(path, body: nil, method: :get)
34
+ response = @client.send(method) do |req|
35
+ req.path = path
36
+ req.headers['Accept'] = 'application/vnd.espago.v3+json'
37
+ req.headers['Authorization'] = "Basic #{encoded_credentials}"
38
+ req.body = body if body
39
+ end
40
+
41
+ ::Response.new(
42
+ connected: true,
43
+ status: response.status,
44
+ body: response.body,
45
+ )
46
+ rescue Sofia::Error::TimeoutError
47
+ ::Response.new(connected: false, body: { error: 'timeout' })
48
+ rescue Sofia::Error::ConnectionFailed
49
+ ::Response.new(connected: false, body: { error: 'connection_failed' })
50
+ rescue Sofia::Error::SSLError
51
+ ::Response.new(connected: false, body: { error: 'ssl_error' })
52
+ rescue Sofia::Error::ParserError
53
+ ::Response.new(connected: false, body: { error: 'parsing_error' })
54
+ rescue URI::InvalidURIError, URI::BadURIError
55
+ ::Response.new(connected: false, body: { error: 'invalid_uri' })
56
+ rescue StandardError
57
+ ::Response.new(connected: false, body: { error: 'unexpected_error' })
58
+ end
59
+
60
+ private
61
+
62
+ #: -> String
63
+ def encoded_credentials
64
+ Base64.strict_encode64("#{@user}:#{@password}")
65
+ end
66
+ end
67
+
68
+ ```
69
+
4
70
 
5
71
  ## Development
6
72
 
@@ -17,34 +17,48 @@ module Sofia
17
17
  # @override
18
18
  #: (Sofia::Request request) -> Sofia::Response
19
19
  def call(request)
20
- uri = URI.parse(request.url)
21
-
22
- http = Net::HTTP.new(uri.host, uri.port)
23
- http.use_ssl = uri.scheme == 'https'
20
+ uri = parse_uri(request.url)
21
+ http = configure_http(uri, request)
22
+ net_req = build_request(uri, request)
23
+ response = perform_request(http, net_req)
24
+ adapt_response(response, request)
25
+ end
24
26
 
25
- klass = Net::HTTP.const_get(request.http_method.capitalize)
27
+ private
26
28
 
29
+ #: (String url) -> URI::HTTP
30
+ def parse_uri(url)
31
+ uri = URI.parse(url)
27
32
  raise Sofia::Error::ArgumentError, 'only HTTP(S) URLs are supported' unless uri.is_a?(URI::HTTP)
28
33
 
29
- net_req = klass.new(uri.request_uri)
34
+ uri
35
+ end
30
36
 
37
+ #: (URI::HTTP uri, Sofia::Request request) -> Net::HTTP
38
+ def configure_http(uri, request)
39
+ http = Net::HTTP.new(uri.host, uri.port)
40
+ http.use_ssl = uri.scheme == 'https'
41
+ http.read_timeout = request.options.read_timeout.to_f
42
+ http.write_timeout = request.options.write_timeout.to_f
43
+ http.open_timeout = request.options.connection_timeout.to_f
44
+ http
45
+ end
46
+
47
+ #: (URI::HTTP uri, Sofia::Request request) -> Net::HTTPRequest
48
+ def build_request(uri, request)
49
+ klass = Net::HTTP.const_get(request.http_method.capitalize)
50
+ net_req = klass.new(uri.request_uri)
31
51
  request.headers.each { |k, v| net_req[k] = v }
32
52
  body_hash = request.body.to_h
33
53
  net_req.body = JSON.dump(body_hash) unless body_hash.empty? || request.http_method == :get
34
-
35
-
36
- response = perform_request(http, net_req)
37
-
38
- adapt_response(response, request)
54
+ net_req
39
55
  end
40
56
 
41
- private
42
-
43
57
  #: (Net::HTTPResponse response, Sofia::Request request) -> Sofia::Response
44
58
  def adapt_response(response, request)
45
59
  Sofia::Response.new(
46
60
  status: response.code.to_i,
47
- headers: response.each_header.to_h,
61
+ headers: response.to_hash,
48
62
  raw_body: response.body,
49
63
  request: request,
50
64
  )
@@ -0,0 +1,101 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'soren'
5
+
6
+ module Sofia
7
+ module Adapter
8
+ class Soren < Base
9
+ class << self
10
+
11
+ SOREN_CONNECTION_EXCEPTIONS = T.let(
12
+ [
13
+ ::Soren::Error::ConnectionError,
14
+ ::Soren::Error::ConnectionRefused,
15
+ ::Soren::Error::DNSFailure,
16
+ ].freeze,
17
+ T::Array[T.class_of(::Soren::Error::Base)],
18
+ )
19
+
20
+ SOREN_TIMEOUT_EXCEPTIONS = T.let(
21
+ [
22
+ ::Soren::Error::TimeoutError,
23
+ ::Soren::Error::ReadTimeout,
24
+ ].freeze,
25
+ T::Array[T.class_of(::Soren::Error::Base)],
26
+ )
27
+
28
+ # @override
29
+ #: (Sofia::Request request) -> Sofia::Response
30
+ def call(request)
31
+ uri = parse_uri(request.url)
32
+ connection = build_connection(uri, request)
33
+ soren_req = build_request(uri, request)
34
+ response = perform_request(connection, soren_req)
35
+ adapt_response(response, request)
36
+ end
37
+
38
+ private
39
+
40
+ #: (String url) -> URI::HTTP
41
+ def parse_uri(url)
42
+ uri = URI.parse(url)
43
+ raise Sofia::Error::ArgumentError, 'only HTTP(S) URLs are supported' unless uri.is_a?(URI::HTTP)
44
+
45
+ uri
46
+ end
47
+
48
+ #: (URI::HTTP uri, Sofia::Request request) -> ::Soren::Connection
49
+ def build_connection(uri, request)
50
+ ::Soren::Connection.new(
51
+ host: uri.host,
52
+ port: uri.port,
53
+ scheme: uri.scheme,
54
+ options: {
55
+ connect_timeout: request.options.connection_timeout.to_f,
56
+ read_timeout: request.options.read_timeout.to_f,
57
+ write_timeout: request.options.write_timeout.to_f,
58
+ },
59
+ )
60
+ end
61
+
62
+ #: (URI::HTTP uri, Sofia::Request request) -> ::Soren::Request
63
+ def build_request(uri, request)
64
+ body_hash = request.body.to_h
65
+ body = body_hash.empty? || request.http_method == :get ? nil : JSON.dump(body_hash)
66
+
67
+ ::Soren::Request.new(
68
+ method: request.http_method.to_s,
69
+ target: uri.request_uri,
70
+ headers: request.headers.to_h,
71
+ body: body,
72
+ )
73
+ end
74
+
75
+ #: (::Soren::Connection connection, ::Soren::Request soren_req) -> ::Soren::Response
76
+ def perform_request(connection, soren_req)
77
+ connection.send(soren_req)
78
+ rescue ::Soren::Error::SSLError => e
79
+ raise Sofia::Error::SSLError, e
80
+ rescue *SOREN_CONNECTION_EXCEPTIONS => e
81
+ raise Sofia::Error::ConnectionFailed, e
82
+ rescue *SOREN_TIMEOUT_EXCEPTIONS => e
83
+ raise Sofia::Error::TimeoutError, e
84
+ rescue ::Soren::Error::ParseError => e
85
+ raise Sofia::Error::ParserError, e
86
+ end
87
+
88
+ #: (::Soren::Response response, Sofia::Request request) -> Sofia::Response
89
+ def adapt_response(response, request)
90
+ Sofia::Response.new(
91
+ status: response.code,
92
+ headers: response.headers,
93
+ raw_body: response.body,
94
+ request: request,
95
+ )
96
+ end
97
+
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/sofia/adapter.rb CHANGED
@@ -18,3 +18,4 @@ module Sofia
18
18
  end
19
19
 
20
20
  require_relative 'adapter/net_http'
21
+ require_relative 'adapter/soren'
data/lib/sofia/client.rb CHANGED
@@ -5,10 +5,11 @@ module Sofia
5
5
  class Client
6
6
  HTTP_METHODS = %w[get post put patch delete].freeze
7
7
 
8
- #: (base_url: untyped, adapter: untyped) -> void
9
- def initialize(base_url:, adapter:)
10
- @base_url = Sofia::Types::BaseUrl.new(base_url) #: Sofia::Types::BaseUrl
11
- @adapter = Sofia::Types::Adapter.new(adapter) #: Sofia::Types::Adapter
8
+ #: (base_url: untyped, adapter: untyped, ?options: untyped) -> void
9
+ def initialize(base_url:, adapter:, options: nil)
10
+ @base_url = Sofia::Types::Client::BaseUrl.new(base_url) #: Sofia::Types::Client::BaseUrl
11
+ @adapter = Sofia::Types::Client::Adapter.new(adapter) #: Sofia::Types::Client::Adapter
12
+ @options = Sofia::Types::Client::Options.new(options).value #: Sofia::Options
12
13
  end
13
14
 
14
15
  #: -> String
@@ -21,6 +22,9 @@ module Sofia
21
22
  @adapter.to_sym
22
23
  end
23
24
 
25
+ #: Sofia::Options
26
+ attr_reader :options
27
+
24
28
  #: ?{ (Request req) -> untyped } -> Response
25
29
  def get(&) = request(:get, &)
26
30
 
@@ -45,6 +49,7 @@ module Sofia
45
49
  req = Request.new(
46
50
  http_method: http_method,
47
51
  base_url: @base_url,
52
+ options: @options,
48
53
  )
49
54
 
50
55
  block.call(req)
@@ -0,0 +1,12 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Sofia
5
+ module Defaults
6
+ module Timeouts
7
+ READ_TIMEOUT = 30
8
+ WRITE_TIMEOUT = 30
9
+ CONNECTION_TIMEOUT = 10
10
+ end
11
+ end
12
+ end
@@ -2,8 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Sofia
5
- module Helper
6
- end
5
+ module Defaults; end
7
6
  end
8
7
 
9
- require_relative 'helper/params'
8
+ require_relative 'defaults/timeouts'
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Sofia
5
5
  module Error
6
- class InvalidJSON < Base; end
6
+ class ParserError < Base; end
7
7
  end
8
8
  end
data/lib/sofia/error.rb CHANGED
@@ -10,5 +10,5 @@ end
10
10
  require_relative 'error/connection_failed'
11
11
  require_relative 'error/ssl_error'
12
12
  require_relative 'error/timeout_error'
13
- require_relative 'error/invalid_json'
13
+ require_relative 'error/parser_error'
14
14
  require_relative 'error/argument_error'
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Sofia
5
+ class Options
6
+
7
+ #: Sofia::Types::Options::Timeout::Read
8
+ attr_reader :read_timeout
9
+
10
+ #: Sofia::Types::Options::Timeout::Write
11
+ attr_reader :write_timeout
12
+
13
+ #: Sofia::Types::Options::Timeout::Connection
14
+ attr_reader :connection_timeout
15
+
16
+ #: (?read_timeout: untyped, ?write_timeout: untyped, ?connection_timeout: untyped) -> void
17
+ def initialize(read_timeout: nil, write_timeout: nil, connection_timeout: nil)
18
+ @read_timeout = Sofia::Types::Options::Timeout::Read.new(
19
+ read_timeout || Sofia::Defaults::Timeouts::READ_TIMEOUT,
20
+ ) #: Sofia::Types::Options::Timeout::Read
21
+ @write_timeout = Sofia::Types::Options::Timeout::Write.new(
22
+ write_timeout || Sofia::Defaults::Timeouts::WRITE_TIMEOUT,
23
+ ) #: Sofia::Types::Options::Timeout::Write
24
+ @connection_timeout = Sofia::Types::Options::Timeout::Connection.new(
25
+ connection_timeout || Sofia::Defaults::Timeouts::CONNECTION_TIMEOUT,
26
+ ) #: Sofia::Types::Options::Timeout::Connection
27
+ end
28
+ end
29
+ end
data/lib/sofia/request.rb CHANGED
@@ -7,43 +7,47 @@ module Sofia
7
7
  #: Symbol
8
8
  attr_reader :http_method
9
9
 
10
- #: Sofia::Types::Headers
10
+ #: Sofia::Types::Client::Headers
11
11
  attr_reader :headers
12
12
 
13
- #: Sofia::Types::Params
13
+ #: Sofia::Types::Client::Params
14
14
  attr_reader :params
15
15
 
16
- #: Sofia::Types::Body
16
+ #: Sofia::Types::Client::Body
17
17
  attr_reader :body
18
18
 
19
- #: (http_method: Symbol, base_url: Sofia::Types::BaseUrl) -> void
20
- def initialize(http_method:, base_url:)
19
+ #: Sofia::Options
20
+ attr_reader :options
21
+
22
+ #: (http_method: Symbol, base_url: Sofia::Types::Client::BaseUrl, ?options: Sofia::Options) -> void
23
+ def initialize(http_method:, base_url:, options: Sofia::Options.new)
21
24
  @http_method = http_method
22
- @base_url = base_url
23
- @path = Sofia::Types::Path.new #: Sofia::Types::Path
24
- @params = Sofia::Types::Params.new #: Sofia::Types::Params
25
- @headers = Sofia::Types::Headers.new #: Sofia::Types::Headers
26
- @body = Sofia::Types::Body.new #: Sofia::Types::Body
25
+ @base_url = base_url
26
+ @options = options #: Sofia::Options
27
+ @path = Sofia::Types::Client::Path.new #: Sofia::Types::Client::Path
28
+ @params = Sofia::Types::Client::Params.new #: Sofia::Types::Client::Params
29
+ @headers = Sofia::Types::Client::Headers.new #: Sofia::Types::Client::Headers
30
+ @body = Sofia::Types::Client::Body.new #: Sofia::Types::Client::Body
27
31
  end
28
32
 
29
33
  #: (untyped path) -> void
30
34
  def path=(path)
31
- @path = Sofia::Types::Path.new(path)
35
+ @path = Sofia::Types::Client::Path.new(path)
32
36
  end
33
37
 
34
38
  #: (untyped params) -> void
35
39
  def params=(params)
36
- @params = Sofia::Types::Params.new(params)
40
+ @params = Sofia::Types::Client::Params.new(params)
37
41
  end
38
42
 
39
43
  #: (untyped headers) -> void
40
44
  def headers=(headers)
41
- @headers = Sofia::Types::Headers.new(headers)
45
+ @headers = Sofia::Types::Client::Headers.new(headers)
42
46
  end
43
47
 
44
48
  #: (untyped? body) -> void
45
49
  def body=(body)
46
- @body = Sofia::Types::Body.new(body)
50
+ @body = Sofia::Types::Client::Body.new(body)
47
51
  end
48
52
 
49
53
  #: -> String
@@ -7,7 +7,7 @@ module Sofia
7
7
  #: Integer
8
8
  attr_reader :status
9
9
 
10
- #: Hash[String, String]
10
+ #: Hash[String, Array[String]]
11
11
  attr_reader :headers
12
12
 
13
13
  #: String?
@@ -16,7 +16,7 @@ module Sofia
16
16
  #: Sofia::Request
17
17
  attr_reader :request
18
18
 
19
- #: (status: Integer, headers: Hash[String, String], raw_body: String?, request: Sofia::Request) -> void
19
+ #: (status: Integer, headers: Hash[String, Array[String]], raw_body: String?, request: Sofia::Request) -> void
20
20
  def initialize(status:, headers:, raw_body:, request:)
21
21
  @status = status
22
22
  @headers = headers
@@ -41,7 +41,7 @@ module Sofia
41
41
 
42
42
  JSON.parse(body)
43
43
  rescue JSON::ParserError
44
- raise Sofia::Error::InvalidJSON
44
+ raise Sofia::Error::ParserError
45
45
  end
46
46
 
47
47
  #: -> bool
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Sofia
5
+ module Types
6
+ module Client
7
+ class Adapter
8
+
9
+ #: singleton(Sofia::Adapter::Base)
10
+ attr_reader :klass
11
+
12
+ #: (?untyped adapter) -> void
13
+ def initialize(adapter = :net_http)
14
+ name, klass = validate_and_set(adapter || :net_http)
15
+ @name = T.let(name, Symbol)
16
+ @klass = T.let(klass, T.class_of(Sofia::Adapter::Base))
17
+ end
18
+
19
+ #: -> Symbol
20
+ def to_sym
21
+ @name
22
+ end
23
+
24
+ private
25
+
26
+ #: (untyped) -> [Symbol, singleton(Sofia::Adapter::Base)]
27
+ def validate_and_set(adapter)
28
+ case adapter&.to_sym
29
+ when :net_http
30
+ [:net_http, Sofia::Adapter::NetHTTP]
31
+ when :soren
32
+ [:soren, Sofia::Adapter::Soren]
33
+ else
34
+ Kernel.raise Sofia::Error::ArgumentError, "unknown adapter #{adapter}"
35
+ end
36
+
37
+ rescue NoMethodError
38
+ Kernel.raise Sofia::Error::ArgumentError, "unknown adapter #{adapter}"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Sofia
5
+ module Types
6
+ module Client
7
+ class BaseUrl
8
+
9
+ #: (untyped base_url) -> void
10
+ def initialize(base_url)
11
+ @base_url = validate_and_normalize(base_url).freeze #: String
12
+ end
13
+
14
+ #: -> String
15
+ def to_s
16
+ @base_url
17
+ end
18
+
19
+ private
20
+
21
+ #: (untyped) -> String
22
+ def validate_and_normalize(base_url)
23
+ unless base_url.is_a?(String) && !base_url.strip.empty?
24
+ raise Sofia::Error::ArgumentError, 'base_url must be a non-empty String'
25
+ end
26
+
27
+ normalized = base_url.chomp('/')
28
+ return normalized if normalized.start_with?('http://', 'https://')
29
+
30
+ "https://#{normalized}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Sofia
5
+ module Types
6
+ module Client
7
+ class Body
8
+
9
+ #: Hash[String, untyped]
10
+ attr_reader :body
11
+
12
+ #: (?untyped? body) -> void
13
+ def initialize(body = nil)
14
+ @body = validate_and_normalize(body) #: Hash[String, untyped]
15
+ end
16
+
17
+ #: -> Hash[String, untyped]
18
+ def to_h
19
+ @body.dup
20
+ end
21
+
22
+ private
23
+
24
+ #: (untyped? body) -> Hash[String, untyped]
25
+ def validate_and_normalize(body)
26
+ return {} unless body
27
+
28
+ Kernel.raise Sofia::Error::ArgumentError, 'body must be a Hash' unless body.is_a?(Hash)
29
+ body.each_with_object({}) do |(key, value), acc|
30
+ next if value.nil?
31
+
32
+ acc[key.to_s] =
33
+ case value
34
+ when Hash
35
+ validate_and_normalize(value)
36
+ when Array
37
+ validate_and_normalize_array(value)
38
+ else
39
+ value
40
+ end
41
+ end
42
+ end
43
+
44
+ #: (Array[untyped]) -> Array[untyped]
45
+ def validate_and_normalize_array(array)
46
+ array.each_with_object([]) do |value, acc|
47
+ next if value.nil?
48
+
49
+ acc << case value
50
+ when Hash
51
+ validate_and_normalize(value)
52
+ when Array
53
+ validate_and_normalize_array(value)
54
+ else
55
+ value
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end