-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(basecamp): move HTTP plumbing to Connections::Rest #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module ActiveProject | ||
| module Connections | ||
| # Shared helpers used by both REST and GraphQL modules, | ||
| # because every cult needs a doctrine of "Don't Repeat Yourself." | ||
| module Base | ||
| # ------------------------------------------------------------------ | ||
| # RFC 5988 Link header parsing | ||
| # ------------------------------------------------------------------ | ||
| # | ||
| # Parses your classic overengineered pagination headers. | ||
| # | ||
| # <https://api.example.com/…?page=2>; rel="next", | ||
| # <https://api.example.com/…?page=5>; rel="last" | ||
| # | ||
| # REST’s way of pretending it’s not just guessing how many pages there are. | ||
| # | ||
| # @param header [String, nil] | ||
| # @return [Hash{String => String}] map of rel => absolute URL | ||
| def parse_link_header(header) | ||
| return {} unless header # Always a good first step: check if we’ve been given absolutely nothing. | ||
|
|
||
| header.split(",").each_with_object({}) do |part, acc| | ||
| url, rel = part.split(";", 2) | ||
| next unless url && rel | ||
|
|
||
| url = url[/\<([^>]+)\>/, 1] # Pull the sacred URL from its <> temple. | ||
| rel = rel[/rel="?([^\";]+)"?/, 1] # Decode the rel tag, likely “next,” “prev,” or “you tried.” | ||
| acc[rel] = url if url && rel | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Converts Faraday/HTTP errors into custom ActiveProject errors, | ||
| # to reflect the spiritual disharmony between client and server. | ||
| def map_faraday_error(err) | ||
| status = err.response_status | ||
| body = err.response_body.to_s | ||
| msg = begin | ||
| JSON.parse(body)["message"] # Attempt to extract meaning from chaos. | ||
| rescue StandardError | ||
| body # If that fails, just channel the entire unfiltered anguish. | ||
| end | ||
|
|
||
| case status | ||
| when 401, 403 | ||
| ActiveProject::AuthenticationError.new(msg) # Root chakra blocked. You’re not grounded. Or authenticated. | ||
| when 404 | ||
| ActiveProject::NotFoundError.new(msg) # Sacral chakra disturbance. The thing you desire does not exist. Embrace the void. | ||
| when 429 | ||
| ActiveProject::RateLimitError.new(msg) # Solar plexus overload. You asked too much. Sit in a quiet room and contemplate restraint. | ||
| when 400, 422 | ||
| ActiveProject::ValidationError.new(msg, status_code: status, response_body: body) # Heart chakra misalignment. Your intentions were pure, but malformed. | ||
| else | ||
| ActiveProject::ApiError.new("HTTP #{status || 'N/A'}: #{msg}", | ||
| original_error: err, | ||
| status_code: status, | ||
| response_body: body) # Crown chakra shattered. The cosmos has rejected your request. Seek inner peace. Or fix your payload. | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module ActiveProject | ||
| module Connections | ||
| # Supposedly "reusable" GraphQL connection logic. | ||
| # Because clearly REST wasn't performative enough, and now we need a whole query language | ||
| # to fetch a user's email address. | ||
| module GraphQl | ||
| include Base | ||
| include Pagination # Because apparently, every five-item list deserves its own saga. | ||
|
|
||
| # Initializes the GraphQL connection. Requires an endpoint, a token, | ||
| # an optional auth header, and—if it still doesn't work—maybe a goat sacrifice. | ||
| # Bonus points if you time it around Eid al-Adha or Yom Kippur. | ||
| # Nothing says "API design" like invoking Abrahamic tension. | ||
| def init_graphql(endpoint:, token:, auth_header: "Authorization") | ||
| build_connection( | ||
| base_url: endpoint, | ||
| auth_middleware: ->(c) { c.headers[auth_header] = "Bearer #{token}" }, # Because "Bearer" is short for "Bear responsibility for this mess." | ||
| extra_headers: { "Content-Type" => "application/json" } # Of course it's JSON. The one part we *didn't* reinvent. | ||
| ) | ||
| end | ||
|
|
||
| # Executes a GraphQL POST request. Because normal HTTP verbs had too much dignity. | ||
| # | ||
| # @return [Hash] The "data" part, which is always buried under a mountain of abstract misery. | ||
| def request_gql(query:, variables: {}) | ||
| payload = { query: query, variables: variables }.to_json | ||
| res = request(:post, "", body: payload) | ||
| raise_graphql_errors!(res) # Make sure to decode the latest prophecy from the Error Oracle. | ||
| res["data"] | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Reads GraphQL's emotional breakdown and turns it into exceptions. | ||
| def raise_graphql_errors!(result) | ||
| errs = result["errors"] | ||
| return unless errs&.any? | ||
|
|
||
| # Combine all the cryptic complaints into one helpful panic attack. | ||
| msg = errs.map { |e| e["message"] }.join("; ") | ||
|
|
||
| case msg | ||
| when /unauth/i | ||
| raise ActiveProject::AuthenticationError, msg # Because you're authenticated... just not enough. | ||
| when /not\s+found|unknown id/i | ||
| raise ActiveProject::NotFoundError, msg # Either it doesn't exist or you made it up. Who can say. | ||
| else | ||
| raise ActiveProject::ValidationError.new(msg, errors: errs) # Catch-all error, because your query's vibes were off. | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module ActiveProject | ||
| module Connections | ||
| # Relay + Link-header pagination helpers usable for REST and GraphQL | ||
| module Pagination | ||
| include HttpClient | ||
| # Generic RFC-5988 “Link” header paginator (GitHub/Jira/Trello style) | ||
| # | ||
| # @yieldparam page [Object] parsed JSON for each HTTP page | ||
| def each_page(path, method: :get, body: nil, query: {}, headers: {}) | ||
| next_url = path | ||
| loop do | ||
| page = request(method, next_url, body: body, query: query, headers: headers) | ||
| yield page | ||
| link_header = @last_response&.headers&.[]("Link") | ||
| next_url = parse_link_header(link_header)["next"] | ||
| break unless next_url | ||
| # After first request we follow absolute URLs; zero out body/query for GETs | ||
| body = nil if method == :get | ||
| query = {} | ||
| end | ||
| end | ||
|
|
||
| # Relay-style paginator (pageInfo{ hasNextPage, endCursor }) | ||
| # | ||
| # @param connection_path [Array<String>] path inside JSON to the connection node | ||
| # @yieldparam node [Object] each edge.node yielded | ||
| def each_edge(query:, connection_path:, variables: {}, after_key: "after") | ||
| cursor = nil | ||
| loop do | ||
| vars = variables.merge(after_key => cursor) | ||
| data = yield(vars) # caller executes GraphQL request, returns data hash | ||
| conn = data.dig(*connection_path) | ||
| conn["edges"].each { |edge| yield_edge(edge["node"]) } | ||
| break unless conn["pageInfo"]["hasNextPage"] | ||
| cursor = conn["pageInfo"]["endCursor"] | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.