diff --git a/.beads/.local_version b/.beads/.local_version new file mode 100644 index 0000000..ae6dd4e --- /dev/null +++ b/.beads/.local_version @@ -0,0 +1 @@ +0.29.0 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f632fa9..3e24478 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,14 +1,14 @@ -{"id":"nb-2","title":"Add Network Error Test Coverage","description":"Implement tests for timeout scenarios and network error handling to ensure resilience","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-02T17:09:16.462163+07:00","updated_at":"2025-12-02T17:26:17.183337+07:00"} -{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2025-12-02T17:26:17.185491+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} -{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2025-12-02T17:26:17.184279+07:00"} -{"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} -{"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} -{"id":"nb-1","title":"Events Resource","description":"Create Events resource with list, create, show, update, delete operations including RSVP management and event-specific filtering capabilities","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:35.786297+07:00","updated_at":"2025-12-02T17:26:17.182348+07:00","dependencies":[{"issue_id":"nb-1","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:15.193639+07:00","created_by":"daemon"}]} -{"id":"nb-13","title":"Enhance HTTP Error Context","description":"Add request method and path to error messages for improved debugging (e.g., 'Authentication failed for GET /people')","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:08.185816+07:00","updated_at":"2025-12-02T17:26:17.187371+07:00"} -{"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} -{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2025-12-02T17:26:17.185816+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} -{"id":"nb-10","title":"Production Memory Adapter Warning","description":"Add runtime warning when Memory adapter is used in production Rails environments to prevent accidental production usage","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:13.087752+07:00","updated_at":"2025-12-02T17:26:17.186615+07:00"} -{"id":"nb-12","title":"Add Explicit Linter Configuration","description":"Create .standard.yml file for explicit StandardRB configuration and team consistency beyond defaults","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-02T17:09:14.308639+07:00","updated_at":"2025-12-02T17:26:17.187136+07:00"} -{"id":"nb-14","title":"Centralize OAuth HTTP Calls","description":"Refactor OAuth module to use HttpClient instead of direct HTTP.post calls for consistent logging and error handling across all HTTP operations","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:09.633647+07:00","updated_at":"2025-12-02T17:26:17.187606+07:00"} -{"id":"nb-11","title":"Donations Resource","description":"Build Donations endpoint support with list/filter/pagination, create, show, update operations and proper handling of money values, dates, and donor relationships","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:34.642315+07:00","updated_at":"2025-12-02T17:26:17.186862+07:00","dependencies":[{"issue_id":"nb-11","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:13.279819+07:00","created_by":"daemon"}]} -{"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} +{"id":"nb-1","title":"Events Resource","description":"Create Events resource with list, create, show, update, delete operations including RSVP management and event-specific filtering capabilities","design":"Create lib/nationbuilder_api/resources/events.rb with RSVP support. See roadmap item #16.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:35.786297+07:00","updated_at":"2025-12-02T17:26:17.182348+07:00","dependencies":[{"issue_id":"nb-1","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:15.193639+07:00","created_by":"daemon"}]} +{"id":"nb-10","title":"Production Memory Adapter Warning","description":"Add runtime warning when Memory adapter is used in production Rails environments to prevent accidental production usage","design":"Add check in Memory adapter initialization that logs warning if Rails.env.production?. See roadmap item #11.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:13.087752+07:00","updated_at":"2025-12-02T17:26:17.186615+07:00"} +{"id":"nb-11","title":"Donations Resource","description":"Build Donations endpoint support with list/filter/pagination, create, show, update operations and proper handling of money values, dates, and donor relationships","design":"Create lib/nationbuilder_api/resources/donations.rb. Handle money formatting, date parsing. See roadmap item #15.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:34.642315+07:00","updated_at":"2025-12-02T17:26:17.186862+07:00","dependencies":[{"issue_id":"nb-11","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:13.279819+07:00","created_by":"daemon"}]} +{"id":"nb-12","title":"Add Explicit Linter Configuration","description":"Create .standard.yml file for explicit StandardRB configuration and team consistency beyond defaults","design":"Add .standard.yml with project-specific linter rules. See roadmap item #12.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-02T17:09:14.308639+07:00","updated_at":"2025-12-02T17:26:17.187136+07:00"} +{"id":"nb-13","title":"Enhance HTTP Error Context","description":"Add request method and path to error messages for improved debugging (e.g., 'Authentication failed for GET /people')","design":"Update error raising in http_client.rb handle_response method to include method and path context. See roadmap item #8.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:08.185816+07:00","updated_at":"2025-12-04T10:16:52.99755+07:00","closed_at":"2025-12-04T10:16:52.99755+07:00"} +{"id":"nb-14","title":"Centralize OAuth HTTP Calls","description":"Refactor OAuth module to use HttpClient instead of direct HTTP.post calls for consistent logging and error handling across all HTTP operations","design":"Replace Net::HTTP direct calls in oauth.rb with HttpClient calls. Ensure token exchange and refresh operations use centralized client. See roadmap item #9.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:09.633647+07:00","updated_at":"2025-12-04T10:25:59.122885+07:00","closed_at":"2025-12-04T10:25:59.122885+07:00"} +{"id":"nb-2","title":"Add Network Error Test Coverage","description":"Implement tests for timeout scenarios and network error handling to ensure resilience","design":"Add tests for Net::OpenTimeout, Net::ReadTimeout, connection refused scenarios. See roadmap item #13.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-02T17:09:16.462163+07:00","updated_at":"2025-12-02T17:26:17.183337+07:00"} +{"id":"nb-3","title":"Add Redis \u0026 ActiveRecord Adapter Tests","description":"Implement comprehensive test suites for Redis and ActiveRecord token storage adapters to match Memory adapter test coverage. This addresses 3 failing ActiveRecord tests found in verification.","design":"Add test files spec/nationbuilder_api/token_storage/redis_spec.rb and enhance active_record_spec.rb. Include store_token, retrieve_token, delete_token, serialization tests. See roadmap item #7.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-02T17:09:06.202895+07:00","updated_at":"2025-12-02T17:32:40.986334+07:00","closed_at":"2025-12-02T17:32:40.986334+07:00"} +{"id":"nb-4","title":"Fix Token Adapter Validation","description":"Correct validate_adapter_interface! method in client.rb to use respond_to? instead of instance_methods set difference for accurate interface validation","design":"Change from instance_methods set comparison to respond_to? checks for each required method (get_token, store_token, delete_token). See roadmap item #6.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:04.540968+07:00","updated_at":"2025-12-02T17:26:17.184025+07:00","closed_at":"2025-12-02T17:18:07.625684+07:00"} +{"id":"nb-5","title":"Response Object Pattern","description":"Wrap API responses in typed objects instead of raw hashes for better developer experience and type safety (e.g., Person, Donation objects)","design":"Create response object classes in lib/nationbuilder_api/response_objects/. Use method_missing or define_method for attribute access. See roadmap item #18.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:38.473982+07:00","updated_at":"2025-12-02T17:26:17.184279+07:00"} +{"id":"nb-6","title":"Tags Resource","description":"Implement tag management with list tags, apply tag to person, remove tag from person, and bulk tagging operations for managing supporter segments","design":"Create lib/nationbuilder_api/resources/tags.rb. Support bulk operations. See roadmap item #17.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:36.894093+07:00","updated_at":"2025-12-02T17:26:17.185491+07:00","dependencies":[{"issue_id":"nb-6","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:17.88942+07:00","created_by":"daemon"}]} +{"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2025-12-02T17:26:17.185816+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} +{"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} +{"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} diff --git a/Gemfile.lock b/Gemfile.lock index 125d412..61b83d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - nationbuilder-client-v2 (0.1.0) + nationbuilder-client-v2 (0.2.0) base64 (>= 0.1.0) logger (>= 1.4.0) diff --git a/README.md b/README.md index 24825fd..509654d 100644 --- a/README.md +++ b/README.md @@ -99,25 +99,69 @@ token_data = client.exchange_code_for_token( ### 3. Make Authenticated API Requests ```ruby -# Tokens are automatically refreshed when needed -people = client.get('/people') +# Using resource methods (recommended) +person = client.people.show(123) +taggings = client.people.taggings(123) +rsvps = client.people.rsvps(123) +activities = client.people.activities(123) + +# Or make direct API calls +people = client.get('/api/v1/people') # Create a new person -person = client.post('/people', body: { - first_name: 'John', - last_name: 'Doe', - email: 'john.doe@example.com' +person = client.post('/api/v1/people', body: { + person: { + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com' + } }) # Update a person -client.patch("/people/#{person[:id]}", body: { - first_name: 'Jane' +client.patch("/api/v1/people/#{person[:id]}", body: { + person: { first_name: 'Jane' } }) # Delete a person -client.delete("/people/#{person[:id]}") +client.delete("/api/v1/people/#{person[:id]}") +``` + +## API Resources + +### People Resource + +The People resource provides convenient methods for working with NationBuilder people data using the V2 API with JSON:API format: + +```ruby +# Fetch person details (V2 API - JSON:API format) +person = client.people.show(123) +# => { data: { type: "person", id: "123", attributes: { first_name: "John", ... } } } + +# Fetch person with taggings sideloaded +person_with_tags = client.people.show(123, include_taggings: true) +# => { data: { ... }, included: [{ type: "tagging", ... }] } + +# Get person's taggings/subscriptions (V2 API - JSON:API format) +# This is a convenience method that calls show() with include_taggings: true +taggings = client.people.taggings(123) +# => { data: { ... }, included: [{ type: "tagging", ... }] } + +# Get person's event RSVPs (V2 API - JSON:API format) +rsvps = client.people.rsvps(123) +# => { data: [...], included: [... event details ...] } + +# Exclude event details from RSVP response +rsvps = client.people.rsvps(123, include_event: false) +# => { data: [...] } + +# Get person's recent activities (V1 API - will migrate to V2 when available) +# Note: This endpoint may not be available on all NationBuilder accounts +activities = client.people.activities(123) +# => { results: [{ type: "email_sent", created_at: "...", ... }] } ``` +**Note**: The People resource uses the V2 API by default, which returns data in JSON:API format. Only the `activities()` method still uses V1 as the V2 equivalent is not yet available. + ## Configuration Options ### Global Configuration @@ -339,11 +383,11 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Version -Current version: **0.1.0** (Phase 1 - Core Infrastructure) +Current version: **0.2.0** (Phase 2 - People API Resource) ### Roadmap -- **Phase 1 (v0.1.0)** - OAuth, token management, HTTP client infrastructure -- **Phase 2 (v0.2.0)** - API resources (People, Donations, Events, Tags) -- **Phase 3 (v0.3.0)** - Pagination, rate limiting, webhooks, batch operations -- **Phase 4 (v1.0.0)** - Rails generators, comprehensive docs, testing utilities +- **Phase 1 (v0.1.0)** ✅ OAuth, token management, HTTP client infrastructure +- **Phase 2 (v0.2.0)** ✅ People API resource (show, taggings, rsvps, activities) +- **Phase 3 (v0.3.0)** - Additional resources (Donations, Events, Tags), pagination, rate limiting +- **Phase 4 (v1.0.0)** - Webhooks, batch operations, Rails generators, comprehensive docs, testing utilities diff --git a/lib/nationbuilder_api.rb b/lib/nationbuilder_api.rb index e067351..8b6ae3a 100644 --- a/lib/nationbuilder_api.rb +++ b/lib/nationbuilder_api.rb @@ -18,6 +18,12 @@ module TokenStorage autoload :ActiveRecord, "nationbuilder_api/token_storage/active_record" end + # API resources + module Resources + autoload :Base, "nationbuilder_api/resources/base" + autoload :People, "nationbuilder_api/resources/people" + end + # OAuth scope constants SCOPE_PEOPLE_READ = "people:read" SCOPE_PEOPLE_WRITE = "people:write" diff --git a/lib/nationbuilder_api/client.rb b/lib/nationbuilder_api/client.rb index d885587..a37a606 100644 --- a/lib/nationbuilder_api/client.rb +++ b/lib/nationbuilder_api/client.rb @@ -56,7 +56,8 @@ def exchange_code_for_token(code:, code_verifier:, identifier: nil) client_secret: config.client_secret, redirect_uri: config.redirect_uri, code_verifier: code_verifier, - oauth_base_url: oauth_base_url + oauth_base_url: oauth_base_url, + logger: @logger ) @token_adapter.store_token(identifier, token_data) @@ -77,7 +78,8 @@ def refresh_token(identifier: nil) refresh_token: token_data[:refresh_token], client_id: config.client_id, client_secret: config.client_secret, - oauth_base_url: oauth_base_url + oauth_base_url: oauth_base_url, + logger: @logger ) @token_adapter.refresh_token(identifier, new_token_data) @@ -136,6 +138,18 @@ def delete(path) http_client.delete(path) end + # Access People resource + # + # @return [Resources::People] People resource instance + # + # @example + # client.people.show(123) + # client.people.taggings(123) + # client.people.rsvps(123) + def people + @people ||= Resources::People.new(self) + end + private # Extract OAuth base URL from API base URL diff --git a/lib/nationbuilder_api/http_client.rb b/lib/nationbuilder_api/http_client.rb index 02fd153..21c5251 100644 --- a/lib/nationbuilder_api/http_client.rb +++ b/lib/nationbuilder_api/http_client.rb @@ -186,7 +186,8 @@ def refresh_token!(refresh_token) refresh_token: refresh_token, client_id: config.client_id, client_secret: config.client_secret, - oauth_base_url: oauth_base_url + oauth_base_url: oauth_base_url, + logger: @logger ) @token_adapter.refresh_token(@identifier, new_token_data) diff --git a/lib/nationbuilder_api/http_request.rb b/lib/nationbuilder_api/http_request.rb new file mode 100644 index 0000000..c2a4dfd --- /dev/null +++ b/lib/nationbuilder_api/http_request.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" + +module NationbuilderApi + # Low-level HTTP request module shared by OAuth and HttpClient + # Provides consistent logging and error handling for all HTTP operations + module HttpRequest + class << self + # Make an HTTP POST request with form data + # + # @param url [String] Full URL for the request + # @param params [Hash] Form parameters + # @param timeout [Integer] Timeout in seconds (default: 30) + # @param logger [Logger, nil] Logger instance for request/response logging + # @return [Net::HTTPResponse] Raw HTTP response + # @raise [NetworkError] On network or timeout errors + def post_form(url, params, timeout: 30, logger: nil) + uri = URI(url) + start_time = Time.now + + # Log request if logger provided + logger&.log_request(:post, url, headers: {"Content-Type" => "application/x-www-form-urlencoded"}, body: params) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = timeout + http.open_timeout = timeout + # SSL verification is always enabled for security + + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/x-www-form-urlencoded" + request.set_form_data(params) + + response = http.request(request) + + # Log response if logger provided + if logger + duration_ms = ((Time.now - start_time) * 1000).round + logger.log_response(response.code.to_i, duration_ms, headers: response.to_hash, body: response.body) + end + + response + rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e + raise NetworkError.new("Network error for POST #{url}: #{e.message}", response: nil) + end + end + end +end diff --git a/lib/nationbuilder_api/oauth.rb b/lib/nationbuilder_api/oauth.rb index 9d39fdc..a7e9f67 100644 --- a/lib/nationbuilder_api/oauth.rb +++ b/lib/nationbuilder_api/oauth.rb @@ -5,7 +5,7 @@ require "base64" require "uri" require "json" -require "net/http" +require_relative "http_request" module NationbuilderApi # OAuth 2.0 with PKCE implementation @@ -75,8 +75,9 @@ def authorization_url(client_id:, redirect_uri:, scopes: [], state: nil, code_ve # @param redirect_uri [String] OAuth callback URL (must match authorization) # @param code_verifier [String] PKCE code verifier # @param oauth_base_url [String, nil] Base URL for OAuth endpoints (e.g., "https://yournation.nationbuilder.com") + # @param logger [Logger, nil] Logger instance for request/response logging # @return [Hash] Token data - def exchange_code_for_token(code:, client_id:, client_secret:, redirect_uri:, code_verifier:, oauth_base_url: nil) + def exchange_code_for_token(code:, client_id:, client_secret:, redirect_uri:, code_verifier:, oauth_base_url: nil, logger: nil) params = { grant_type: "authorization_code", code: code, @@ -88,7 +89,7 @@ def exchange_code_for_token(code:, client_id:, client_secret:, redirect_uri:, co # Use nation-specific OAuth URL if provided, otherwise fall back to default token_url = oauth_base_url ? "#{oauth_base_url}/oauth/token" : TOKEN_URL - response = make_token_request(token_url, params) + response = make_token_request(token_url, params, logger: logger) parse_token_response(response) end @@ -99,8 +100,9 @@ def exchange_code_for_token(code:, client_id:, client_secret:, redirect_uri:, co # @param client_id [String] OAuth client ID # @param client_secret [String] OAuth client secret # @param oauth_base_url [String, nil] Base URL for OAuth endpoints (e.g., "https://yournation.nationbuilder.com") + # @param logger [Logger, nil] Logger instance for request/response logging # @return [Hash] Token data - def refresh_access_token(refresh_token:, client_id:, client_secret:, oauth_base_url: nil) + def refresh_access_token(refresh_token:, client_id:, client_secret:, oauth_base_url: nil, logger: nil) params = { grant_type: "refresh_token", refresh_token: refresh_token, @@ -110,7 +112,7 @@ def refresh_access_token(refresh_token:, client_id:, client_secret:, oauth_base_ # Use nation-specific OAuth URL if provided, otherwise fall back to default token_url = oauth_base_url ? "#{oauth_base_url}/oauth/token" : TOKEN_URL - response = make_token_request(token_url, params) + response = make_token_request(token_url, params, logger: logger) parse_token_response(response) end @@ -128,27 +130,9 @@ def token_expired?(expires_at, buffer_seconds: 60) private # Make HTTP POST request for token exchange - # Uses Net::HTTP with SSL configuration - def http_client - # This method name is kept for compatibility but now creates a Net::HTTP request - # The actual POST is done in the make_token_request method - self - end - - def make_token_request(url, params) - uri = URI(url) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - http.open_timeout = 30 - # SSL verification is always enabled for security - # Use OpenSSL::SSL::VERIFY_PEER by default (Ruby's default) - - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/x-www-form-urlencoded" - request.set_form_data(params) - - http.request(request) + # Uses shared HttpRequest module for consistent logging and error handling + def make_token_request(url, params, logger: nil) + HttpRequest.post_form(url, params, timeout: 30, logger: logger) end def parse_token_response(response) diff --git a/lib/nationbuilder_api/resources/base.rb b/lib/nationbuilder_api/resources/base.rb new file mode 100644 index 0000000..ca2bed2 --- /dev/null +++ b/lib/nationbuilder_api/resources/base.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module NationbuilderApi + module Resources + # Base class for API resource wrappers + # Provides common functionality for making HTTP requests via the client + class Base + attr_reader :client + + def initialize(client) + @client = client + end + + private + + # Make GET request to API + # @param path [String] API endpoint path + # @param params [Hash] Query parameters + # @return [Hash, Array] Parsed response + def get(path, params: {}) + client.get(path, params: params) + end + + # Make POST request to API + # @param path [String] API endpoint path + # @param body [Hash] Request body + # @return [Hash, Array] Parsed response + def post(path, body: {}) + client.post(path, body: body) + end + + # Make PATCH request to API + # @param path [String] API endpoint path + # @param body [Hash] Request body + # @return [Hash, Array] Parsed response + def patch(path, body: {}) + client.patch(path, body: body) + end + + # Make PUT request to API + # @param path [String] API endpoint path + # @param body [Hash] Request body + # @return [Hash, Array] Parsed response + def put(path, body: {}) + client.put(path, body: body) + end + + # Make DELETE request to API + # @param path [String] API endpoint path + # @return [Hash, Array] Parsed response + def delete(path) + client.delete(path) + end + end + end +end diff --git a/lib/nationbuilder_api/resources/people.rb b/lib/nationbuilder_api/resources/people.rb new file mode 100644 index 0000000..391fcf2 --- /dev/null +++ b/lib/nationbuilder_api/resources/people.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module NationbuilderApi + module Resources + # People API resource + # Provides access to NationBuilder People endpoints for retrieving person data, + # taggings, RSVPs, and activities + class People < Base + # Fetch a person by ID + # Uses V2 API with JSON:API format + # + # @param id [String, Integer] Person ID + # @param include_taggings [Boolean] Whether to sideload taggings (default: false) + # @return [Hash] Person data in JSON:API format + # + # @example + # client.people.show(123) + # # => { data: { type: "person", id: "123", attributes: { ... } } } + # + # @example With taggings sideloaded + # client.people.show(123, include_taggings: true) + # # => { data: { ... }, included: [{ type: "tagging", ... }] } + def show(id, include_taggings: false) + path = "/api/v2/people/#{id}" + path += "?include=taggings" if include_taggings + get(path) + end + + # Fetch a person's taggings (subscriptions/lists) + # Uses V2 API with JSON:API format via sideloading on the person endpoint + # + # @param id [String, Integer] Person ID + # @return [Hash] Person data with taggings in JSON:API format + # + # @example + # client.people.taggings(123) + # # => { data: { ... }, included: [{ type: "tagging", ... }] } + def taggings(id) + show(id, include_taggings: true) + end + + # Fetch a person's event RSVPs + # Uses V2 API with JSON:API format and filters by person_id + # + # @param id [String, Integer] Person ID + # @param include_event [Boolean] Whether to include event details (default: true) + # @return [Hash] Event RSVPs data in JSON:API format + # + # @example + # client.people.rsvps(123) + # # => { data: [...], included: [...] } + def rsvps(id, include_event: true) + params = {filter: {person_id: id}} + params[:include] = "event" if include_event + + # Build query string manually for nested filter parameter + query_string = build_query_string(params) + get("/api/v2/event_rsvps?#{query_string}") + end + + # Fetch a person's recent activities + # Note: Uses V1 API as activities endpoint is not yet available in V2 + # This endpoint may not be available on all NationBuilder accounts + # + # @param id [String, Integer] Person ID + # @return [Hash] Activities data with results array (V1 format) + # @raise [NotFoundError] If activities endpoint is not available + # + # @example + # client.people.activities(123) + # # => { results: [{ type: "email_sent", created_at: "...", ... }, ...] } + def activities(id) + # TODO: Migrate to V2 when activities endpoint becomes available + get("/api/v1/people/#{id}/activities") + end + + private + + # Build query string for complex parameters (like nested filters) + # Handles nested hashes for filter parameters + # + # @param params [Hash] Parameters to convert to query string + # @return [String] URL-encoded query string + def build_query_string(params) + flat_params = flatten_params(params) + URI.encode_www_form(flat_params) + end + + # Flatten nested hash parameters for query string + # Converts { filter: { person_id: 123 } } to { "filter[person_id]" => 123 } + # + # @param params [Hash] Nested parameters + # @param prefix [String, nil] Prefix for nested keys + # @return [Array] Flat array of [key, value] pairs + def flatten_params(params, prefix = nil) + result = [] + + params.each do |key, value| + full_key = prefix ? "#{prefix}[#{key}]" : key.to_s + + if value.is_a?(Hash) + result.concat(flatten_params(value, full_key)) + else + result << [full_key, value] + end + end + + result + end + end + end +end diff --git a/lib/nationbuilder_api/version.rb b/lib/nationbuilder_api/version.rb index 49edf12..8950392 100644 --- a/lib/nationbuilder_api/version.rb +++ b/lib/nationbuilder_api/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module NationbuilderApi - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/spec/nationbuilder_api/resources/people_spec.rb b/spec/nationbuilder_api/resources/people_spec.rb new file mode 100644 index 0000000..bcb1ff4 --- /dev/null +++ b/spec/nationbuilder_api/resources/people_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::Resources::People do + let(:client) do + instance_double( + NationbuilderApi::Client, + get: nil, + post: nil, + patch: nil, + delete: nil + ) + end + + subject(:people) { described_class.new(client) } + + describe "#show" do + it "makes GET request to /api/v2/people/:id" do + expect(client).to receive(:get).with("/api/v2/people/123", params: {}) + people.show(123) + end + + it "returns person data in JSON:API format" do + person_data = { + data: { + type: "person", + id: "123", + attributes: { + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + } + } + + allow(client).to receive(:get).and_return(person_data) + result = people.show(123) + + expect(result).to eq(person_data) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v2/people/456", params: {}) + people.show("456") + end + + it "includes taggings when requested" do + expect(client).to receive(:get).with("/api/v2/people/123?include=taggings", params: {}) + people.show(123, include_taggings: true) + end + end + + describe "#taggings" do + it "makes GET request to /api/v2/people/:id with taggings included" do + expect(client).to receive(:get).with("/api/v2/people/123?include=taggings", params: {}) + people.taggings(123) + end + + it "returns person data with taggings in JSON:API format" do + taggings_data = { + data: { + type: "person", + id: "123", + attributes: {first_name: "John"} + }, + included: [ + {type: "tagging", id: "1", attributes: {tag: "volunteer"}}, + {type: "tagging", id: "2", attributes: {tag: "donor"}} + ] + } + + allow(client).to receive(:get).and_return(taggings_data) + result = people.taggings(123) + + expect(result).to eq(taggings_data) + expect(result[:included].length).to eq(2) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v2/people/789?include=taggings", params: {}) + people.taggings("789") + end + end + + describe "#rsvps" do + it "makes GET request to /api/v2/event_rsvps with filter parameter" do + expected_query = "filter%5Bperson_id%5D=123&include=event" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps(123) + end + + it "includes event by default" do + expected_query = "filter%5Bperson_id%5D=123&include=event" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps(123) + end + + it "excludes event when include_event is false" do + expected_query = "filter%5Bperson_id%5D=123" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps(123, include_event: false) + end + + it "returns JSON:API formatted data" do + rsvp_data = { + data: [ + { + type: "event_rsvp", + id: "1", + attributes: {status: "yes"}, + relationships: { + event: {data: {type: "event", id: "100"}} + } + } + ], + included: [ + { + type: "event", + id: "100", + attributes: { + name: "Town Hall", + starts_at: "2025-01-15T18:00:00Z" + } + } + ] + } + + allow(client).to receive(:get).and_return(rsvp_data) + result = people.rsvps(123) + + expect(result).to eq(rsvp_data) + expect(result[:data].length).to eq(1) + expect(result[:included].length).to eq(1) + end + + it "accepts string ID" do + expected_query = "filter%5Bperson_id%5D=456&include=event" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps("456") + end + end + + describe "#activities" do + it "makes GET request to /api/v1/people/:id/activities" do + expect(client).to receive(:get).with("/api/v1/people/123/activities", params: {}) + people.activities(123) + end + + it "returns activities data with results array" do + activities_data = { + results: [ + {type: "email_sent", created_at: "2024-12-01T10:00:00Z"}, + {type: "page_view", created_at: "2024-12-02T14:30:00Z"} + ] + } + + allow(client).to receive(:get).and_return(activities_data) + result = people.activities(123) + + expect(result).to eq(activities_data) + expect(result[:results].length).to eq(2) + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v1/people/999/activities", params: {}) + people.activities("999") + end + + it "raises NotFoundError if endpoint is not available" do + allow(client).to receive(:get) + .and_raise(NationbuilderApi::NotFoundError, "Endpoint not found") + + expect { + people.activities(123) + }.to raise_error(NationbuilderApi::NotFoundError) + end + end + + describe "query string building" do + it "properly encodes nested filter parameters" do + # Test the query string building by checking the actual call + expected_query = "filter%5Bperson_id%5D=123&include=event" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps(123) + end + + it "handles string IDs in filters" do + expected_query = "filter%5Bperson_id%5D=abc123&include=event" + expect(client).to receive(:get) + .with("/api/v2/event_rsvps?#{expected_query}", params: {}) + + people.rsvps("abc123") + end + end +end