congrega_plenum 0.1.0

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.
data/lib/client.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CongregaPlenum
4
+ # HTTP client responsible for talking to the Câmara API endpoints and dealing
5
+ # with retries, pagination and response parsing.
6
+ #
7
+ # All methods are thread-safe because the heavy collaborators ({HttpAdapter},
8
+ # {RetryPolicy} and {ResponseHandler}) are stateless.
9
+ class Client
10
+ include Singleton
11
+
12
+ attr_reader :http_adapter, :retry_policy, :response_handler
13
+
14
+ def initialize
15
+ configuration = CongregaPlenum.configuration
16
+ @http_adapter = HttpAdapter.new(configuration: configuration)
17
+ @retry_policy = RetryPolicy.new(configuration: configuration)
18
+ @response_handler = ResponseHandler.new
19
+ end
20
+
21
+ # Performs a single HTTP GET to the given +endpoint+ and returns the parsed
22
+ # JSON body.
23
+ #
24
+ # @param endpoint [String] the relative path (e.g. +deputados+)
25
+ # @param params [Hash] query parameters merged into the request
26
+ # @return [Hash] parsed response body
27
+ def get(endpoint, params = {})
28
+ url = build_url(endpoint, params)
29
+
30
+ retry_policy.with_retries(url) { execute_request(url) }
31
+ end
32
+
33
+ # Retrieves all pages for an endpoint, flattening the +dados+ payloads into a
34
+ # single array. Automatically respects the configured rate limit delay.
35
+ #
36
+ # @param endpoint [String]
37
+ # @param params [Hash]
38
+ # @return [Array<Hash>]
39
+ def get_paginated(endpoint, params = {})
40
+ # @type var results: Array[Hash[String, untyped]]
41
+ results = []
42
+ each_response_page(endpoint, params) { |page_data| results.concat(page_data) }
43
+
44
+ results
45
+ end
46
+
47
+ private
48
+
49
+ def each_response_page(endpoint, params)
50
+ page = 1
51
+
52
+ loop do
53
+ response = request_page(endpoint, params, page)
54
+ data = response.fetch('dados', [])
55
+ break if data.empty?
56
+
57
+ yield(data)
58
+ break unless next_page?(response)
59
+
60
+ page += 1
61
+ apply_rate_limit_delay
62
+ end
63
+ end
64
+
65
+ # Fetches a single page enforcing +formato=json+ and the requested page index.
66
+ def request_page(endpoint, params, page)
67
+ get(endpoint, params.merge(pagina: page, formato: 'json'))
68
+ end
69
+
70
+ def next_page?(response)
71
+ response.fetch('links', []).any? { |link| link['rel'] == 'next' }
72
+ end
73
+
74
+ # Sleep between page fetches to respect the rate limit exposed by the API.
75
+ # This is configurable because not every consumer has the same tolerance.
76
+ def apply_rate_limit_delay
77
+ delay = configuration.rate_limit_delay
78
+
79
+ sleep(delay) if delay.positive?
80
+ end
81
+
82
+ def execute_request(url)
83
+ response = http_adapter.get(url)
84
+
85
+ response_handler.handle(response, url)
86
+ end
87
+
88
+ # Builds the full URL pointing to the Câmara API, ensuring +formato=json+ is present.
89
+ #
90
+ # @param endpoint [String]
91
+ # @param params [Hash]
92
+ # @return [String]
93
+ def build_url(endpoint, params = {})
94
+ uri = URI("#{configuration.base_url}/#{endpoint.gsub(%r{^/}, '')}")
95
+
96
+ # Ensure formato=json is always present
97
+ normalized_params = params.transform_keys(&:to_sym)
98
+ normalized_params[:formato] ||= 'json'
99
+
100
+ uri.query = URI.encode_www_form(normalized_params)
101
+
102
+ uri.to_s
103
+ end
104
+
105
+ def configuration
106
+ CongregaPlenum.configuration
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+
5
+ require 'net/http'
6
+ require 'json'
7
+ require 'uri'
8
+ require 'singleton'
9
+ require 'logger'
10
+
11
+ # CongregaPlenum centralizes access to the Brazilian Chamber of Deputies public API.
12
+ # It exposes a global configuration and service objects for each resource.
13
+ module CongregaPlenum
14
+ # Base error class for all domain-specific exceptions in the gem.
15
+ class Error < StandardError; end
16
+ # Raised when connectivity fails before receiving a valid HTTP response.
17
+ class ConnectionError < Error; end
18
+ # Raised when the API responds with an unexpected status code or payload.
19
+ class APIError < Error; end
20
+ # Raised for transient 5xx responses that are safe to retry.
21
+ class ServerError < APIError; end
22
+ # Raised when the API signals that the rate limit has been exceeded.
23
+ class RateLimitError < Error; end
24
+
25
+ class << self
26
+ attr_accessor :configuration
27
+ end
28
+
29
+ # Yields the current {Configuration} so callers can override defaults.
30
+ #
31
+ # @yieldparam config [Configuration] the mutable configuration object
32
+ def self.configure
33
+ self.configuration ||= Configuration.new
34
+
35
+ yield(configuration)
36
+ end
37
+
38
+ # Shared configuration used by the client and services to tweak base URL,
39
+ # timeouts and retry policies. Defaults focus on the public APIs hosted by the
40
+ # Câmara so a regular application can simply call {CongregaPlenum.configure} and
41
+ # override what differs (timeouts, logger, etc.).
42
+ class Configuration
43
+ # @return [String] Base endpoint for all requests.
44
+ attr_accessor :base_url
45
+ # @return [Integer] Timeout (seconds) applied to open/read operations.
46
+ attr_accessor :timeout
47
+ # @return [Integer] How many times requests are retried on transient errors.
48
+ attr_accessor :retries
49
+ # @return [Float] Initial backoff delay in seconds.
50
+ attr_accessor :retry_delay
51
+ # @return [Float] Delay between paginated calls to respect rate limits.
52
+ attr_accessor :rate_limit_delay
53
+ # @return [Logger] Logger used across adapters/services.
54
+ attr_accessor :logger
55
+
56
+ # Builds a configuration instance with safe defaults tuned for the Câmara APIs.
57
+ # Those endpoints are known to be HTTPS-only, relatively slow, and rate-limited,
58
+ # so the defaults try to smooth that out.
59
+ def initialize
60
+ @base_url = 'https://dadosabertos.camara.leg.br/api/v2'
61
+ @timeout = 30
62
+ @retries = 3
63
+ @retry_delay = 1.0
64
+ @rate_limit_delay = 0.1
65
+ @logger = Logger.new($stdout)
66
+ end
67
+ end
68
+
69
+ # Initialize with default configuration
70
+ configure do |_config|
71
+ # Defaults are already set in Configuration#initialize
72
+ end
73
+ end
74
+
75
+ # Load all sub-modules
76
+ require_relative 'adapters/http_adapter'
77
+ require_relative 'adapters/retry_policy'
78
+ require_relative 'adapters/response_handler'
79
+ require_relative 'client'
80
+ require_relative 'modules/congressmen_service'
81
+ require_relative 'modules/parties_service'
82
+ require_relative 'modules/legislatures_service'
83
+ require_relative 'modules/votings_service'
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CongregaPlenum
4
+ # Service responsible for interacting with congressmen endpoints, wrapping both
5
+ # list and detail requests into high level helpers.
6
+ class CongressmenService
7
+ # Default page size for list endpoints.
8
+ ITEMS_PER_PAGE = 100
9
+ # Interval used to emit progress logs while iterating.
10
+ PROGRESS_INTERVAL = 50
11
+ # Tag prefix for structured logging.
12
+ SERVICE_TAG = 'CongregaPlenum::CongressmenService'
13
+
14
+ class << self
15
+ # Shared client instance configured via {CongregaPlenum.configure}.
16
+ #
17
+ # @return [CongregaPlenum::Client]
18
+ def client
19
+ @client ||= CongregaPlenum::Client.instance
20
+ end
21
+
22
+ # Wraps {Client#get} to add service context without hiding request failures.
23
+ def api_get(endpoint, params = {})
24
+ client.get(endpoint, params)
25
+ rescue StandardError => e
26
+ log_error("Erro ao acessar API em #{endpoint}: #{e.message}")
27
+ raise
28
+ end
29
+
30
+ # Same as {.api_get}, but for bulk pagination calls. Failures are propagated
31
+ # so consumers can abort an incomplete synchronization.
32
+ def api_get_paginated(endpoint, params = {})
33
+ client.get_paginated(endpoint, params)
34
+ rescue StandardError => e
35
+ log_error("Erro paginando API em #{endpoint}: #{e.message}")
36
+ raise
37
+ end
38
+
39
+ # Fetches the entire list of congressmen regardless of legislature.
40
+ #
41
+ # @return [Array<Hash>]
42
+ def fetch_all
43
+ fetch_deputies_collection(context: 'todos os deputados', params: { itens: ITEMS_PER_PAGE })
44
+ end
45
+
46
+ # Fetches all congressmen for a given legislature, enriching each entry with
47
+ # its detailed payload.
48
+ #
49
+ # @param legislature_id [Integer]
50
+ # @return [Array<Hash>]
51
+ def fetch_all_by_legislature(legislature_id)
52
+ fetch_deputies_collection(
53
+ context: "legislatura #{legislature_id}",
54
+ params: { itens: ITEMS_PER_PAGE, idLegislatura: legislature_id }
55
+ )
56
+ end
57
+
58
+ # Retrieves the detailed payload of a single congressman.
59
+ #
60
+ # @param deputy_id [Integer]
61
+ # @return [Hash, nil]
62
+ # @raise [CongregaPlenum::APIError] if +dados+ has an unexpected type
63
+ def fetch_by_id(deputy_id)
64
+ log_debug("Buscando deputado #{deputy_id}")
65
+
66
+ response = api_get("deputados/#{deputy_id}")
67
+ extract_detail(response, "deputado #{deputy_id}")
68
+ end
69
+
70
+ # Returns a paginated list of congressmen straight from the API, without
71
+ # making detail calls.
72
+ #
73
+ # @param page [Integer]
74
+ # @param items_per_page [Integer]
75
+ # @param legislature_id [Integer,nil]
76
+ # @return [Array<Hash>]
77
+ def fetch_list(page: 1, items_per_page: ITEMS_PER_PAGE, legislature_id: nil)
78
+ log_debug("Buscando lista de deputados página #{page}")
79
+
80
+ params = { pagina: page, itens: items_per_page }
81
+ params[:idLegislatura] = legislature_id if legislature_id
82
+
83
+ response = api_get('deputados', params)
84
+ response['dados'] || []
85
+ end
86
+
87
+ private
88
+
89
+ def extract_detail(response, resource)
90
+ detail = response['dados']
91
+ return detail if detail.nil? || detail.is_a?(Hash)
92
+
93
+ raise APIError, "Resposta inválida para #{resource}: esperado Hash ou nil em dados"
94
+ end
95
+
96
+ # Coordinates pagination and detail fetches so the long running process can
97
+ # log progress and reuse error handling in one place.
98
+ def fetch_deputies_collection(context:, params:)
99
+ log_info("Iniciando coleta de #{context}")
100
+
101
+ deputies = api_get_paginated('deputados', params)
102
+
103
+ log_info("Coletamos #{deputies.length} registros de #{context}")
104
+
105
+ detailed_deputies = build_detailed_deputies(deputies, context)
106
+
107
+ log_info("Finalizamos a coleta detalhada de #{detailed_deputies.length} registros para #{context}")
108
+ detailed_deputies
109
+ end
110
+
111
+ # Walks the basic list and replaces each entry with its detailed payload.
112
+ # Having this call separated from {#fetch_deputies_collection} keeps the
113
+ # public method compact and enables targeted testing of the detail logic.
114
+ def build_detailed_deputies(deputies, context)
115
+ deputies.each_with_index.with_object([]) do |(deputy, index), collected|
116
+ detailed_deputy = fetch_by_id(deputy['id'])
117
+ collected << detailed_deputy if detailed_deputy
118
+ log_progress(index, deputies.length, context)
119
+ end
120
+ end
121
+
122
+ # Emits progress logs every {PROGRESS_INTERVAL} items so long running
123
+ # synchronisations give users feedback without spamming the console.
124
+ def log_progress(index, total, context)
125
+ return unless ((index + 1) % PROGRESS_INTERVAL).zero?
126
+
127
+ log_info("Processamos #{index + 1}/#{total} registros para #{context}")
128
+ end
129
+
130
+ def logger
131
+ CongregaPlenum.configuration.logger
132
+ end
133
+
134
+ def log_info(message)
135
+ logger.info("#{SERVICE_TAG}: #{message}")
136
+ end
137
+
138
+ def log_error(message)
139
+ logger.error("#{SERVICE_TAG}: #{message}")
140
+ end
141
+
142
+ def log_debug(message)
143
+ logger.debug("#{SERVICE_TAG}: #{message}")
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CongregaPlenum
4
+ # Service responsible for interacting with legislature endpoints including mesa
5
+ # composition and deputies per legislature.
6
+ class LegislaturesService
7
+ # Default pagination size for legislature listings.
8
+ PAGE_SIZE = 50
9
+ # Pagination size for deputies per legislature.
10
+ DEPUTIES_PAGE_SIZE = 100
11
+ # Tag prefix for structured logging.
12
+ SERVICE_TAG = 'CongregaPlenum::LegislaturesService'
13
+
14
+ class << self
15
+ # Shared client instance configured via {CongregaPlenum.configure}.
16
+ #
17
+ # @return [CongregaPlenum::Client]
18
+ def client
19
+ @client ||= CongregaPlenum::Client.instance
20
+ end
21
+
22
+ # Wraps {Client#get} to add service context without hiding request failures.
23
+ def api_get(endpoint, params = {})
24
+ client.get(endpoint, params)
25
+ rescue StandardError => e
26
+ log_error("Erro ao acessar API em #{endpoint}: #{e.message}")
27
+ raise
28
+ end
29
+
30
+ # Bulk variant of {.api_get}. Failures are propagated so consumers can
31
+ # abort an incomplete synchronization.
32
+ def api_get_paginated(endpoint, params = {})
33
+ client.get_paginated(endpoint, params)
34
+ rescue StandardError => e
35
+ log_error("Erro paginando API em #{endpoint}: #{e.message}")
36
+ raise
37
+ end
38
+
39
+ # Returns every legislature exposed by a API.
40
+ #
41
+ # @return [Array<Hash>]
42
+ def fetch_all
43
+ log_info('Iniciando busca de todas as legislaturas')
44
+
45
+ legislatures = api_get_paginated('legislaturas', itens: PAGE_SIZE)
46
+
47
+ log_info("Total de #{legislatures.size} legislaturas encontradas")
48
+ legislatures
49
+ end
50
+
51
+ # Fetches a single legislature payload.
52
+ #
53
+ # @param legislature_id [Integer]
54
+ # @return [Hash, nil]
55
+ # @raise [CongregaPlenum::APIError] if +dados+ has an unexpected type
56
+ def fetch_by_id(legislature_id)
57
+ log_debug("Buscando legislatura #{legislature_id}")
58
+
59
+ response = api_get("legislaturas/#{legislature_id}")
60
+ extract_detail(response, "legislatura #{legislature_id}")
61
+ end
62
+
63
+ # Retrieves the mesa composition for the provided legislature ID.
64
+ #
65
+ # @param legislature_id [Integer]
66
+ # @return [Array<Hash>]
67
+ def fetch_mesa(legislature_id)
68
+ response = api_get("legislaturas/#{legislature_id}/mesa")
69
+ mesa_data = response['dados'] || []
70
+
71
+ log_info("Mesa da legislatura #{legislature_id} coletada com #{mesa_data.size} integrantes")
72
+ mesa_data
73
+ end
74
+
75
+ # Lists every deputy that served/serves in the given legislature using the
76
+ # +idLegislatura+ filter supported by the deputies endpoint.
77
+ #
78
+ # @param legislature_id [Integer]
79
+ # @return [Array<Hash>]
80
+ def fetch_deputies(legislature_id)
81
+ log_info("Iniciando coleta de deputados da legislatura #{legislature_id}")
82
+
83
+ deputies = api_get_paginated('deputados', itens: DEPUTIES_PAGE_SIZE, idLegislatura: legislature_id)
84
+
85
+ log_info("Coletamos #{deputies.size} deputados para a legislatura #{legislature_id}")
86
+ deputies
87
+ end
88
+
89
+ private
90
+
91
+ def extract_detail(response, resource)
92
+ detail = response['dados']
93
+ return detail if detail.nil? || detail.is_a?(Hash)
94
+
95
+ raise APIError, "Resposta inválida para #{resource}: esperado Hash ou nil em dados"
96
+ end
97
+
98
+ def logger
99
+ CongregaPlenum.configuration.logger
100
+ end
101
+
102
+ def log_info(message)
103
+ logger.info("#{SERVICE_TAG}: #{message}")
104
+ end
105
+
106
+ def log_error(message)
107
+ logger.error("#{SERVICE_TAG}: #{message}")
108
+ end
109
+
110
+ def log_debug(message)
111
+ logger.debug("#{SERVICE_TAG}: #{message}")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CongregaPlenum
4
+ # Service responsible for interacting with party endpoints. It exposes class
5
+ # methods that hide pagination, retries and detail lookups.
6
+ class PartiesService
7
+ # Default page size for party listings.
8
+ ITEMS_PER_PAGE = 100
9
+ # Interval used to emit progress logs while iterating.
10
+ PROGRESS_INTERVAL = 20
11
+ # Tag prefix for structured logging.
12
+ SERVICE_TAG = 'CongregaPlenum::PartiesService'
13
+
14
+ class << self
15
+ # Shared client instance configured via {CongregaPlenum.configure}.
16
+ #
17
+ # @return [CongregaPlenum::Client]
18
+ def client
19
+ @client ||= CongregaPlenum::Client.instance
20
+ end
21
+
22
+ # Wraps {Client#get} to add service context without hiding request failures.
23
+ def api_get(endpoint, params = {})
24
+ client.get(endpoint, params)
25
+ rescue StandardError => e
26
+ log_error("Erro ao acessar API em #{endpoint}: #{e.message}")
27
+ raise
28
+ end
29
+
30
+ # Bulk variant of {.api_get}. Failures are propagated so consumers can
31
+ # abort an incomplete synchronization.
32
+ def api_get_paginated(endpoint, params = {})
33
+ client.get_paginated(endpoint, params)
34
+ rescue StandardError => e
35
+ log_error("Erro paginando API em #{endpoint}: #{e.message}")
36
+ raise
37
+ end
38
+
39
+ # Fetches every party registered na API, enriquecendo com payload detalhado.
40
+ #
41
+ # @return [Array<Hash>]
42
+ def fetch_all
43
+ log_info('Iniciando coleta de todos os partidos')
44
+
45
+ parties = api_get_paginated('partidos', itens: ITEMS_PER_PAGE)
46
+
47
+ log_info("Coletamos #{parties.length} partidos da listagem")
48
+
49
+ detailed_parties = build_detailed_parties(parties)
50
+
51
+ log_info("Finalizamos a coleta detalhada de #{detailed_parties.length} partidos")
52
+ detailed_parties
53
+ end
54
+
55
+ # Retrieves the detailed payload for a single party.
56
+ #
57
+ # @param party_id [Integer]
58
+ # @return [Hash, nil]
59
+ # @raise [CongregaPlenum::APIError] if +dados+ has an unexpected type
60
+ def fetch_by_id(party_id)
61
+ log_debug("Buscando partido #{party_id}")
62
+
63
+ response = api_get("partidos/#{party_id}")
64
+ extract_detail(response, "partido #{party_id}")
65
+ end
66
+
67
+ # Returns a paginated list of parties, without triggering detail lookups.
68
+ #
69
+ # @param page [Integer]
70
+ # @param items_per_page [Integer]
71
+ # @return [Array<Hash>]
72
+ def fetch_list(page: 1, items_per_page: ITEMS_PER_PAGE)
73
+ log_debug("Buscando lista de partidos página #{page}")
74
+
75
+ response = api_get('partidos', pagina: page, itens: items_per_page)
76
+ response['dados'] || []
77
+ end
78
+
79
+ private
80
+
81
+ def extract_detail(response, resource)
82
+ detail = response['dados']
83
+ return detail if detail.nil? || detail.is_a?(Hash)
84
+
85
+ raise APIError, "Resposta inválida para #{resource}: esperado Hash ou nil em dados"
86
+ end
87
+
88
+ # Converts the lightweight list into the detailed payload expected by
89
+ # consumers. Extracted to simplify instrumentation/tests.
90
+ def build_detailed_parties(parties)
91
+ parties.each_with_index.with_object([]) do |(party, index), collected|
92
+ detailed_party = fetch_by_id(party['id'])
93
+ collected << detailed_party if detailed_party
94
+ log_progress(index, parties.length)
95
+ end
96
+ end
97
+
98
+ # Emits periodic progress updates so users know long iterations are still
99
+ # alive.
100
+ def log_progress(index, total)
101
+ return unless ((index + 1) % PROGRESS_INTERVAL).zero?
102
+
103
+ log_info("Processamos #{index + 1}/#{total} partidos")
104
+ end
105
+
106
+ def logger
107
+ CongregaPlenum.configuration.logger
108
+ end
109
+
110
+ def log_info(message)
111
+ logger.info("#{SERVICE_TAG}: #{message}")
112
+ end
113
+
114
+ def log_error(message)
115
+ logger.error("#{SERVICE_TAG}: #{message}")
116
+ end
117
+
118
+ def log_debug(message)
119
+ logger.debug("#{SERVICE_TAG}: #{message}")
120
+ end
121
+ end
122
+ end
123
+ end