Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/active_project/adapters/basecamp/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module ActiveProject
module Adapters
module Basecamp
module Connection
include ActiveProject::Adapters::HttpClient
include Connections::Rest
BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
# Initializes the Basecamp Adapter.
# @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
Expand All @@ -20,7 +20,7 @@ def initialize(config:)
account_id = @config.options.fetch(:account_id)
access_token = @config.options.fetch(:access_token)

build_connection(
init_rest(
base_url: format(BASE_URL_TEMPLATE, account_id: account_id),
auth_middleware: ->(conn) { conn.request :authorization, :bearer, access_token }
)
Expand Down
18 changes: 7 additions & 11 deletions lib/active_project/adapters/jira/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Adapters
module Jira
# Low-level HTTP concerns for JiraAdapter
module Connection
include ActiveProject::Adapters::HttpClient
include Connections::Rest

SERAPH_HEADER = "x-seraph-loginreason".freeze

Expand All @@ -28,7 +28,7 @@ def initialize(config:)
username = @config.options.fetch(:username)
api_token = @config.options.fetch(:api_token)

build_connection(
init_rest(
base_url: site_url,
auth_middleware: ->(conn) do
# Faraday’s built-in basic-auth helper :contentReference[oaicite:0]{index=0}
Expand All @@ -49,16 +49,12 @@ def initialize(config:)
#
# @raise [ActiveProject::AuthenticationError] if Jira signals
# AUTHENTICATED_FAILED via X-Seraph-LoginReason header.
private def make_request(method, path, body = nil, query = nil)
data = request(method, path, body: body, query: query)

if @connection.headers[SERAPH_HEADER]&.include?("AUTHENTICATED_FAILED")
# Jira returns 200 + this header when credentials are wrong :contentReference[oaicite:1]{index=1}
raise ActiveProject::AuthenticationError,
"Jira authentication failed (#{SERAPH_HEADER}: AUTHENTICATED_FAILED)"
private def make_request(method, path, body = nil, query = nil, headers = {})
res = request_rest(method, path, body, query, headers)
if @connection.headers["x-seraph-loginreason"]&.include?("AUTHENTICATED_FAILED")
raise ActiveProject::AuthenticationError, "Jira authentication failed"
end

data
res
end
end
end
Expand Down
68 changes: 0 additions & 68 deletions lib/active_project/adapters/pagination.rb

This file was deleted.

4 changes: 2 additions & 2 deletions lib/active_project/adapters/trello/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ module ActiveProject
module Adapters
module Trello
module Connection
include ActiveProject::Adapters::HttpClient
include Connections::Rest

BASE_URL = "https://api.trello.com/1/".freeze

def initialize(config:)
@config = config
build_connection(
init_rest(
base_url: BASE_URL,
auth_middleware: ->(_c) { }, # Trello uses query-string auth
extra_headers: { "Accept" => "application/json" }
Expand Down
65 changes: 65 additions & 0 deletions lib/active_project/connections/base.rb
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
55 changes: 55 additions & 0 deletions lib/active_project/connections/graph_ql.rb
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
require "json"

module ActiveProject
module Adapters
module Connections
module HttpClient
include Pagination
include Base

DEFAULT_HEADERS = {
"Content-Type" => "application/json",
"Accept" => "application/json",
Expand All @@ -17,19 +18,20 @@ module HttpClient

def build_connection(base_url:, auth_middleware:, extra_headers: {})
@connection = Faraday.new(url: base_url) do |conn|
auth_middleware.call(conn) # <-- adapter-specific
conn.request :retry, **RETRY_OPTS
conn.response :raise_error
auth_middleware.call(conn) # Let the adapter sprinkle its secret sauce here.
conn.request :retry, **RETRY_OPTS # Retry like a desperate job applicant in LinkedIn.
conn.response :raise_error # Yes, we want the failure loud and flaming.
default_adapter = ENV.fetch("AP_DEFAULT_ADAPTER", "net_http").to_sym
conn.adapter default_adapter
conn.headers.merge!(DEFAULT_HEADERS.transform_values { |v| v.respond_to?(:call) ? v.call : v })
conn.headers.merge!(extra_headers)
yield conn if block_given? # optional extra tweaks
conn.headers.merge!(extra_headers) # Add your weird little header tweaks here.
yield conn if block_given? # Optional: make it worse with your own block.
end
end

# Sends the HTTP request like a brave little toaster.
def request(method, path, body: nil, query: nil, headers: {})
raise "HTTP connection not initialised" unless @connection
raise "HTTP connection not initialised" unless @connection # You forgot to plug it in. Classic.

json_body = body.is_a?(String) ? body : (body ? JSON.generate(body) : nil)
response = @connection.run_request(method, path, json_body, headers) do |req|
Expand All @@ -49,16 +51,17 @@ def request(method, path, body: nil, query: nil, headers: {})

private

# Converts Faraday’s vague distress signals into more human-readable ActiveProject errors.
def translate_faraday_error(err)
status = err.response_status
body = err.response_body.to_s
msg = begin JSON.parse(body)["message"] rescue body end
msg = begin JSON.parse(body)["message"] rescue body end # Try to find meaning. Fail gracefully.

case status
when 401, 403 then ActiveProject::AuthenticationError.new(msg)
when 404 then ActiveProject::NotFoundError.new(msg)
when 429 then ActiveProject::RateLimitError.new(msg)
when 400, 422 then ActiveProject::ValidationError.new(msg, status_code: status, response_body: body)
when 401, 403 then ActiveProject::AuthenticationError.new(msg) # Your credentials are like George Santos, fake and sad.
when 404 then ActiveProject::NotFoundError.new(msg) # The server looked and found no evidence of the reported crime.
when 429 then ActiveProject::RateLimitError.new(msg) # You angered the rate gods.
when 400, 422 then ActiveProject::ValidationError.new(msg, status_code: status, response_body: body) # You asked wrong.
else
ActiveProject::ApiError.new("HTTP #{status || 'N/A'}: #{msg}",
original_error: err,
Expand Down
42 changes: 42 additions & 0 deletions lib/active_project/connections/pagination.rb
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
Loading