connectors 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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/CONNECTORS_FRAMEWORK.md +799 -0
  4. data/CONTRIBUTING.md +60 -0
  5. data/MCP_CLIENT.md +168 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +146 -0
  8. data/app/connectors/clickup/connector.rb +13 -0
  9. data/app/connectors/gmail/api.rb +114 -0
  10. data/app/connectors/gmail/connector.rb +921 -0
  11. data/app/connectors/gmail/mime_builder.rb +261 -0
  12. data/app/connectors/gmail/mime_parser.rb +106 -0
  13. data/app/connectors/gmail/polling.rb +154 -0
  14. data/app/connectors/remote_mcp/connector.rb +18 -0
  15. data/app/connectors/resend/connector.rb +218 -0
  16. data/app/controllers/concerns/connectors/grant_access.rb +43 -0
  17. data/app/controllers/connectors/actions_controller.rb +66 -0
  18. data/app/controllers/connectors/application_controller.rb +5 -0
  19. data/app/controllers/connectors/credentials_controller.rb +245 -0
  20. data/app/controllers/connectors/grants_controller.rb +123 -0
  21. data/app/controllers/connectors/mcp_controller.rb +88 -0
  22. data/app/controllers/connectors/oauth_controller.rb +134 -0
  23. data/app/controllers/connectors/types_controller.rb +144 -0
  24. data/app/controllers/connectors/webhooks_controller.rb +105 -0
  25. data/app/jobs/connectors/application_job.rb +4 -0
  26. data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
  27. data/app/jobs/connectors/poll_job.rb +49 -0
  28. data/app/models/connectors/application_record.rb +5 -0
  29. data/app/models/connectors/credential_share.rb +31 -0
  30. data/app/models/connectors/grant.rb +103 -0
  31. data/app/models/connectors/mcp_authorization.rb +6 -0
  32. data/app/models/connectors/mcp_interaction.rb +6 -0
  33. data/app/models/connectors/poll_state.rb +17 -0
  34. data/app/models/connectors/webhook_event.rb +19 -0
  35. data/config/routes.rb +68 -0
  36. data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
  37. data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
  38. data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
  39. data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
  40. data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
  41. data/docs/adding-connectors.md +92 -0
  42. data/docs/architecture.md +71 -0
  43. data/docs/releasing.md +60 -0
  44. data/lib/connectors/action.rb +90 -0
  45. data/lib/connectors/action_builder.rb +125 -0
  46. data/lib/connectors/action_runner.rb +99 -0
  47. data/lib/connectors/auth/scheme/api_key.rb +51 -0
  48. data/lib/connectors/auth/scheme/oauth2.rb +23 -0
  49. data/lib/connectors/auth/scheme.rb +35 -0
  50. data/lib/connectors/auth.rb +4 -0
  51. data/lib/connectors/auth_injection.rb +65 -0
  52. data/lib/connectors/client_builder.rb +87 -0
  53. data/lib/connectors/configuration.rb +138 -0
  54. data/lib/connectors/connector.rb +565 -0
  55. data/lib/connectors/credential_schema.rb +219 -0
  56. data/lib/connectors/credential_tester.rb +85 -0
  57. data/lib/connectors/credential_type_registry.rb +162 -0
  58. data/lib/connectors/credential_types/http_auth.rb +171 -0
  59. data/lib/connectors/engine.rb +123 -0
  60. data/lib/connectors/errors.rb +88 -0
  61. data/lib/connectors/grant_policy.rb +13 -0
  62. data/lib/connectors/mcp/access.rb +50 -0
  63. data/lib/connectors/mcp/authorization.rb +177 -0
  64. data/lib/connectors/mcp/authorization_context.rb +25 -0
  65. data/lib/connectors/mcp/authorization_discovery.rb +42 -0
  66. data/lib/connectors/mcp/cancellation.rb +36 -0
  67. data/lib/connectors/mcp/client.rb +137 -0
  68. data/lib/connectors/mcp/connection_config.rb +48 -0
  69. data/lib/connectors/mcp/http.rb +108 -0
  70. data/lib/connectors/mcp/interaction.rb +82 -0
  71. data/lib/connectors/mcp/pending_transaction.rb +26 -0
  72. data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
  73. data/lib/connectors/mcp/protocol/LICENSE +216 -0
  74. data/lib/connectors/mcp/protocol/README.md +8 -0
  75. data/lib/connectors/mcp/protocol_schema.rb +30 -0
  76. data/lib/connectors/mcp/schema.rb +45 -0
  77. data/lib/connectors/mcp/settings.rb +27 -0
  78. data/lib/connectors/mcp/token_endpoint.rb +48 -0
  79. data/lib/connectors/mcp/transport.rb +105 -0
  80. data/lib/connectors/mcp.rb +69 -0
  81. data/lib/connectors/middleware/authenticate_generic.rb +79 -0
  82. data/lib/connectors/middleware/auto_refresh.rb +71 -0
  83. data/lib/connectors/middleware/error_normalization.rb +45 -0
  84. data/lib/connectors/middleware/grant_status.rb +19 -0
  85. data/lib/connectors/middleware/pre_authentication.rb +54 -0
  86. data/lib/connectors/middleware/rate_limit.rb +40 -0
  87. data/lib/connectors/oauth/authorize_url.rb +78 -0
  88. data/lib/connectors/oauth/client_authentication.rb +24 -0
  89. data/lib/connectors/oauth/client_credentials.rb +43 -0
  90. data/lib/connectors/oauth/grant_writer.rb +73 -0
  91. data/lib/connectors/oauth/pkce.rb +32 -0
  92. data/lib/connectors/oauth/revoke.rb +72 -0
  93. data/lib/connectors/oauth/state.rb +41 -0
  94. data/lib/connectors/oauth/token_exchange.rb +69 -0
  95. data/lib/connectors/oauth/token_response.rb +40 -0
  96. data/lib/connectors/oauth.rb +4 -0
  97. data/lib/connectors/oauth1.rb +151 -0
  98. data/lib/connectors/permission_check.rb +33 -0
  99. data/lib/connectors/poll_runner.rb +50 -0
  100. data/lib/connectors/pre_authentication_helpers.rb +76 -0
  101. data/lib/connectors/registry.rb +38 -0
  102. data/lib/connectors/version.rb +3 -0
  103. data/lib/connectors/webhook_context.rb +62 -0
  104. data/lib/connectors/webhook_lifecycle.rb +69 -0
  105. data/lib/connectors/webhook_methods.rb +48 -0
  106. data/lib/connectors/webhooks/verifier.rb +31 -0
  107. data/lib/connectors/webhooks.rb +5 -0
  108. data/lib/connectors.rb +50 -0
  109. data/lib/tasks/connectors_mcp.rake +9 -0
  110. data/lib/tasks/connectors_tasks.rake +4 -0
  111. data/openapi.yaml +1099 -0
  112. metadata +263 -0
@@ -0,0 +1,90 @@
1
+ module Connectors
2
+ # Declarative description of one thing a connector can DO with a credential
3
+ # — Slack "post message", Resend "send email", GitHub "create issue". The
4
+ # equivalent of an n8n node's `(resource, operation)` slot
5
+ # (packages/workflow/src/interfaces.ts NodeProperties) and Activepieces'
6
+ # `createAction(...)` declaration.
7
+ #
8
+ # The class is a value object — `ActionBuilder` materialises it from the
9
+ # DSL, `Connector.actions` stores them, `ActionRunner` invokes them.
10
+ # Frontend / agents read `to_manifest` to know what inputs the action
11
+ # accepts and what shape it returns.
12
+ #
13
+ # action :send_email,
14
+ # display_name: "Send Email",
15
+ # description: "Send a transactional email via Resend." do
16
+ # field :from, type: "string", required: true,
17
+ # placeholder: "Updates <updates@yourdomain.com>"
18
+ # field :to, type: "string", required: true,
19
+ # type_options: { multiple_values: true }
20
+ # field :subject, type: "string", required: true
21
+ # field :html, type: "string", type_options: { editor: "html" }
22
+ # field :text, type: "string", type_options: { rows: 4 }
23
+ #
24
+ # output do
25
+ # field :id, type: "string", description: "Resend message id."
26
+ # end
27
+ #
28
+ # execute do |input|
29
+ # send_email(**input.symbolize_keys)
30
+ # end
31
+ # end
32
+ class Action
33
+ # n8n's `NodePropertyTypes` (interfaces.ts:1561-1584) — a superset of what
34
+ # credentials accept. Action params can be richer than credential fields
35
+ # because they're the user's per-execution inputs, not the auth setup.
36
+ ALLOWED_TYPES = %w[
37
+ string number boolean
38
+ options multiOptions
39
+ json hidden notice
40
+ collection fixedCollection
41
+ dateTime color
42
+ resourceLocator resourceMapper
43
+ filter assignmentCollection
44
+ ].freeze
45
+
46
+ attr_reader :key, :display_name, :description, :params, :output, :execute_block,
47
+ :tags, :deprecated
48
+
49
+ def initialize(key:, display_name:, description: nil,
50
+ params: [], output: [], execute_block: nil,
51
+ tags: [], deprecated: false)
52
+ raise ArgumentError, "execute block is required for action #{key.inspect}" if execute_block.nil?
53
+
54
+ @key = key.to_sym
55
+ @display_name = display_name || key.to_s.tr("_", " ").capitalize
56
+ @description = description
57
+ @params = params
58
+ @output = output
59
+ @execute_block = execute_block
60
+ @tags = Array(tags).map(&:to_sym)
61
+ @deprecated = deprecated == true
62
+ end
63
+
64
+ # Frontend / agent-facing shape. camelCase the keys that go into the
65
+ # property objects so they look identical to credential properties (the
66
+ # generic form renderer handles both with no special-casing). Output
67
+ # shape is the same — fields describing what the runner returns.
68
+ def to_manifest
69
+ {
70
+ name: key.to_s,
71
+ display_name: display_name,
72
+ description: description,
73
+ deprecated: deprecated,
74
+ tags: tags.map(&:to_s),
75
+ properties: params.map(&:to_property),
76
+ output: output.map(&:to_property)
77
+ }.compact
78
+ end
79
+
80
+ # Names of params marked `required: true`. Used by ActionRunner to
81
+ # short-circuit before the execute block runs.
82
+ def required_param_names
83
+ params.select(&:required?).map { |f| f.name.to_s }
84
+ end
85
+
86
+ def param_names
87
+ params.map { |f| f.name.to_s }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,125 @@
1
+ module Connectors
2
+ # DSL surface for the `action` block on a Connector subclass. Collects
3
+ # `field`, optional `output`, and `execute` declarations into an Action
4
+ # value object.
5
+ #
6
+ # Reuses `CredentialSchema::Field` as the property struct because n8n's
7
+ # node-property shape and credential-property shape are identical (both
8
+ # `INodeProperties` at packages/workflow/src/interfaces.ts:1773-1812) —
9
+ # only the allowed `type` set differs (see `Action::ALLOWED_TYPES`).
10
+ class ActionBuilder
11
+ Field = CredentialSchema::Field
12
+
13
+ def self.build(key, display_name: nil, description: nil, tags: [], deprecated: false, &block)
14
+ builder = new(key, display_name: display_name, description: description,
15
+ tags: tags, deprecated: deprecated)
16
+ builder.instance_eval(&block) if block
17
+ builder.to_action
18
+ end
19
+
20
+ def initialize(key, display_name:, description:, tags:, deprecated:)
21
+ @key = key
22
+ @display_name = display_name
23
+ @description = description
24
+ @tags = tags
25
+ @deprecated = deprecated
26
+ @params = []
27
+ @output = []
28
+ @execute_block = nil
29
+ end
30
+
31
+ # Declare one input parameter. Mirrors `CredentialSchema#field` with the
32
+ # broader `Action::ALLOWED_TYPES` set — same kwargs, same semantics.
33
+ def field(name,
34
+ type: "string",
35
+ display_name: nil,
36
+ default: nil,
37
+ placeholder: nil,
38
+ description: nil,
39
+ hint: nil,
40
+ required: false,
41
+ no_data_expression: false,
42
+ type_options: nil,
43
+ display_options: nil,
44
+ options: nil)
45
+ raise ArgumentError, "unknown action field type #{type.inspect}; allowed: #{Action::ALLOWED_TYPES.inspect}" \
46
+ unless Action::ALLOWED_TYPES.include?(type.to_s)
47
+
48
+ @params << Field.new(
49
+ name: name.to_sym,
50
+ display_name: display_name,
51
+ type: type.to_s,
52
+ default: default,
53
+ placeholder: placeholder,
54
+ description: description,
55
+ hint: hint,
56
+ required: required == true,
57
+ no_data_expression: no_data_expression == true,
58
+ type_options: type_options,
59
+ display_options: display_options,
60
+ options: options
61
+ )
62
+ end
63
+
64
+ # Declare the shape of the value the action returns. Optional but
65
+ # encouraged — agents and downstream nodes use it to know what fields
66
+ # they can reference (Activepieces calls this `returns:`). The same
67
+ # `field` DSL is used inside the block.
68
+ def output(&block)
69
+ raise ArgumentError, "output requires a block" if block.nil?
70
+ collector = OutputCollector.new
71
+ collector.instance_eval(&block)
72
+ @output = collector.fields
73
+ end
74
+
75
+ # Capture the execute block. Invoked by `ActionRunner` in the connector
76
+ # instance's context, so `self.client`, `self.send_email`, etc. are all
77
+ # accessible. Receives one positional Hash of params.
78
+ def execute(&block)
79
+ raise ArgumentError, "execute requires a block" if block.nil?
80
+ @execute_block = block
81
+ end
82
+
83
+ def to_action
84
+ Action.new(
85
+ key: @key,
86
+ display_name: @display_name,
87
+ description: @description,
88
+ params: @params,
89
+ output: @output,
90
+ execute_block: @execute_block,
91
+ tags: @tags,
92
+ deprecated: @deprecated
93
+ )
94
+ end
95
+
96
+ # Tiny sibling builder so `output do field ... end` can reuse the same
97
+ # `field` semantics without picking up the outer builder's @params slot.
98
+ class OutputCollector
99
+ attr_reader :fields
100
+
101
+ def initialize
102
+ @fields = []
103
+ end
104
+
105
+ def field(name,
106
+ type: "string",
107
+ display_name: nil,
108
+ description: nil,
109
+ hint: nil,
110
+ type_options: nil,
111
+ options: nil)
112
+ @fields << CredentialSchema::Field.new(
113
+ name: name.to_sym,
114
+ display_name: display_name,
115
+ type: type.to_s,
116
+ description: description,
117
+ hint: hint,
118
+ required: false,
119
+ type_options: type_options,
120
+ options: options
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,99 @@
1
+ module Connectors
2
+ # Validates an Action's input params and invokes its execute block in the
3
+ # connector instance's context. Returns a normalized response envelope —
4
+ # both the controller and the (eventual) workflow executor consume the
5
+ # same shape.
6
+ #
7
+ # result = ActionRunner.call(grant, :send_email, { "from" => "x", "to" => "y", ... })
8
+ # # => { status: "ok", action: "send_email", data: { "id" => "abc-123" } }
9
+ # # or
10
+ # # => { status: "error", action: "send_email",
11
+ # # error: { type: "api_error", message: "...", status: 422 } }
12
+ #
13
+ # The runner does not catch arbitrary StandardError — only Connectors::
14
+ # errors. Anything else propagates so the caller can render a 500. This
15
+ # mirrors how the workflow executor expects "unexpected" failures to be
16
+ # visible in logs rather than silently absorbed.
17
+ class ActionRunner
18
+ OK = "ok".freeze
19
+ ERROR = "error".freeze
20
+
21
+ def self.call(grant, action_name, input)
22
+ new(grant, action_name, input).call
23
+ end
24
+
25
+ def initialize(grant, action_name, input)
26
+ @grant = grant
27
+ @action_name = action_name
28
+ @input = (input || {}).transform_keys(&:to_s)
29
+ end
30
+
31
+ def call
32
+ action = @grant.connector.class.action_lookup(@action_name)
33
+ @grant.ensure_not_revoked!
34
+ validate!(action)
35
+
36
+ result = @grant.connector.instance_exec(coerced_input(action), &action.execute_block)
37
+
38
+ { status: OK, action: action.key.to_s, data: result }
39
+ rescue Connectors::InvalidActionParams => e
40
+ error_envelope(action&.key,
41
+ type: "invalid_params", message: e.message,
42
+ details: { missing: e.missing, unknown: e.unknown })
43
+ rescue Connectors::AuthenticationFailed => e
44
+ error_envelope(action&.key, type: "authentication_failed", message: e.message,
45
+ details: { status: safe_status(e) })
46
+ rescue Connectors::Forbidden => e
47
+ error_envelope(action&.key, type: "forbidden", message: e.message, details: { status: safe_status(e) })
48
+ rescue Connectors::RateLimited => e
49
+ error_envelope(action&.key, type: "rate_limited", message: e.message,
50
+ details: { status: safe_status(e), retry_after: e.retry_after })
51
+ rescue Connectors::ApiError => e
52
+ error_envelope(action&.key, type: "api_error", message: e.message,
53
+ details: { status: safe_status(e), body: e.body })
54
+ rescue Connectors::UnknownAction => e
55
+ # `action` is nil here — re-raise so the controller renders a 404
56
+ # rather than a 200 with an error body (mirrors UnknownConnector).
57
+ raise
58
+ end
59
+
60
+ private
61
+
62
+ def validate!(action)
63
+ provided = @input.keys
64
+ missing = action.required_param_names.reject { |k| @input.key?(k) && !blank?(@input[k]) }
65
+ raise Connectors::InvalidActionParams.new(missing: missing) if missing.any?
66
+ # Note: we intentionally don't reject `unknown:` keys. n8n nodes
67
+ # frequently pass `additionalFields` collections that pack many
68
+ # optional values under one key; the connector's execute block
69
+ # decides which subset to honor. Strict-mode rejection can come
70
+ # later as an opt-in DSL flag.
71
+ _ = provided
72
+ end
73
+
74
+ # Whitelist incoming keys to the action's declared param names so the
75
+ # execute block doesn't see surprise data from a malformed caller. Keys
76
+ # arrive as strings (JSON), which is what every connector method in the
77
+ # codebase keyword-splats from — execute blocks call `**input.symbolize_keys`.
78
+ def coerced_input(action)
79
+ allowed = action.param_names.to_set
80
+ @input.slice(*allowed)
81
+ end
82
+
83
+ def blank?(value)
84
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
85
+ end
86
+
87
+ def safe_status(err)
88
+ err.respond_to?(:status) ? err.status : nil
89
+ end
90
+
91
+ def error_envelope(action_key, type:, message:, details: {})
92
+ {
93
+ status: ERROR,
94
+ action: action_key.to_s,
95
+ error: { type: type, message: message }.merge(details.compact)
96
+ }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,51 @@
1
+ require "cgi"
2
+
3
+ module Connectors
4
+ module Auth
5
+ class Scheme
6
+ # Attaches a static API key from grant.credentials["api_key"]. The
7
+ # location (header vs query), parameter name, and optional prefix are
8
+ # declared by the connector class via the `api_key_in` DSL:
9
+ #
10
+ # class GithubConnector < Connectors::Connector
11
+ # connector key: :github, auth: :api_key, base_url: "https://api.github.com"
12
+ # api_key_in :header, name: "Authorization", prefix: "token "
13
+ # end
14
+ #
15
+ # Default placement is `Authorization: Bearer <key>`.
16
+ class ApiKey < Scheme
17
+ register_as :api_key
18
+
19
+ DEFAULT_OPTIONS = { location: :header, name: "Authorization", prefix: "Bearer " }.freeze
20
+
21
+ def on_request(env)
22
+ key = @grant.credentials_hash["api_key"]
23
+ if key.nil? || key.to_s.empty?
24
+ raise Connectors::AuthenticationFailed.new("grant #{@grant.id} has no api_key")
25
+ end
26
+
27
+ opts = connector_options
28
+ value = "#{opts[:prefix]}#{key}"
29
+
30
+ case opts[:location]
31
+ when :header
32
+ env.request_headers[opts[:name]] = value
33
+ when :query
34
+ existing = env.url.query
35
+ pair = "#{opts[:name]}=#{CGI.escape(value)}"
36
+ env.url.query = [ existing, pair ].compact.reject(&:empty?).join("&")
37
+ else
38
+ raise Connectors::Error.new("ApiKey scheme: unknown location #{opts[:location].inspect}")
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def connector_options
45
+ klass = @grant.connector.class
46
+ (klass.respond_to?(:api_key_options) && klass.api_key_options) || DEFAULT_OPTIONS
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ module Connectors
2
+ module Auth
3
+ class Scheme
4
+ # Attaches an OAuth2 access token as a Bearer Authorization header. The
5
+ # token is read from grant.credentials["access_token"]. Token refresh is
6
+ # handled separately by the AutoRefresh middleware + the connector's
7
+ # #refresh! method.
8
+ class OAuth2 < Scheme
9
+ register_as :oauth2
10
+
11
+ def on_request(env)
12
+ token = @grant.credentials_hash["access_token"]
13
+ if token.nil? || token.to_s.empty?
14
+ raise Connectors::AuthenticationFailed.new(
15
+ "grant #{@grant.id} has no access_token"
16
+ )
17
+ end
18
+ env.request_headers["Authorization"] = "Bearer #{token}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ require "faraday"
2
+
3
+ module Connectors
4
+ module Auth
5
+ # Abstract base class for auth schemes (OAuth2, ApiKey, HMAC, etc.). A
6
+ # scheme is just a Faraday middleware that mutates the outgoing request
7
+ # env with whatever the third-party service expects (Bearer header, signed
8
+ # query, etc.).
9
+ #
10
+ # Subclasses register themselves with a symbolic key via `register_as`,
11
+ # which is what the connector DSL `auth: :oauth2` resolves against.
12
+ class Scheme < Faraday::Middleware
13
+ class << self
14
+ def register_as(name)
15
+ Scheme.registry[name.to_sym] = self
16
+ end
17
+
18
+ def lookup(name)
19
+ registry.fetch(name.to_sym) { raise UnknownAuthScheme.new(name, known: registry.keys) }
20
+ end
21
+
22
+ def registry
23
+ @registry ||= {}
24
+ end
25
+ end
26
+
27
+ def initialize(app, grant:)
28
+ super(app)
29
+ @grant = grant
30
+ end
31
+
32
+ # Subclasses override on_request(env) to attach credentials.
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ module Connectors
2
+ module Auth
3
+ end
4
+ end
@@ -0,0 +1,65 @@
1
+ module Connectors
2
+ # Resolves n8n-style `={{$credentials.x}}` templates inside an
3
+ # `IAuthenticateGeneric` properties block (n8n source:
4
+ # packages/workflow/src/interfaces.ts:269-288, 197-208).
5
+ #
6
+ # Scope on purpose: this is NOT a general-purpose expression engine. It
7
+ # only understands `$credentials.<field>` lookups (with optional bracket
8
+ # access). Anything richer belongs in the automations expression engine,
9
+ # which the connectors engine must remain independent of.
10
+ #
11
+ # A string that starts with `=` is a template — its `{{ ... }}` segments
12
+ # are evaluated. Strings without a leading `=` are passed through as
13
+ # literals (matches n8n's `={{expression}}` convention).
14
+ module AuthInjection
15
+ module_function
16
+
17
+ # Walk a properties Hash (`{ headers: {...}, qs: {...}, body: {...},
18
+ # auth: { username:, password: } }`), resolving every leaf string against
19
+ # `credentials`. Non-string leaves pass through.
20
+ def resolve(properties, credentials)
21
+ walk(properties, credentials || {})
22
+ end
23
+
24
+ def walk(value, credentials)
25
+ case value
26
+ when Hash
27
+ # Both keys AND values can be templates. n8n's HttpHeaderAuth uses
28
+ # `{ '={{$credentials.name}}': '={{$credentials.value}}' }` — the
29
+ # header *name* is user-supplied (see
30
+ # packages/nodes-base/credentials/HttpHeaderAuth.credentials.ts:38-45).
31
+ value.each_with_object({}) do |(k, v), out|
32
+ resolved_key = k.is_a?(String) ? resolve_string(k, credentials) : k
33
+ out[resolved_key] = walk(v, credentials)
34
+ end
35
+ when Array then value.map { |v| walk(v, credentials) }
36
+ when String then resolve_string(value, credentials)
37
+ else value
38
+ end
39
+ end
40
+
41
+ EXPR = /\{\{\s*(.+?)\s*\}\}/.freeze
42
+
43
+ def resolve_string(str, credentials)
44
+ return str unless str.start_with?("=")
45
+ str[1..].gsub(EXPR) { lookup(Regexp.last_match(1), credentials) }
46
+ end
47
+
48
+ # Supports `$credentials.field`, `$credentials["field"]`, `$credentials['field']`.
49
+ # Missing keys resolve to "" (n8n behavior — keeps templates from raising
50
+ # mid-request when the field is optional).
51
+ def lookup(expr, credentials)
52
+ expr = expr.to_s.strip
53
+ if (m = expr.match(/\A\$credentials\.([A-Za-z_][A-Za-z0-9_]*)\z/))
54
+ credentials[m[1]].to_s
55
+ elsif (m = expr.match(/\A\$credentials\[(["'])(.+?)\1\]\z/))
56
+ credentials[m[2]].to_s
57
+ else
58
+ # Unknown expression syntax — pass through verbatim wrapped, so a
59
+ # user typo is visible in the outgoing request rather than silently
60
+ # erasing the header.
61
+ "{{#{expr}}}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,87 @@
1
+ require "faraday"
2
+ require "faraday/retry"
3
+
4
+ module Connectors
5
+ # Assembles the Faraday client used by a connector. Middleware order matters:
6
+ # outermost (declared first) wraps everything below. On the response side,
7
+ # outer middleware runs LAST, so anything that needs the parsed JSON body
8
+ # (ErrorNormalization, AutoRefresh's rescue path) must sit OUTER to the JSON
9
+ # response parser.
10
+ #
11
+ # Stack (outer → inner):
12
+ # 1. JSON request encoder — body marshaling
13
+ # 2. RateLimit — per-grant quota
14
+ # 3. AutoRefresh — rescues 401 -> refresh -> retry
15
+ # 4. ErrorNormalization — raises after retry exhaustion
16
+ # 5. Retry / JSON response parser — inspect parsed responses
17
+ # 6. GrantStatus — reject revoked grants on each attempt
18
+ # 7. PreAuthentication (Phase 3) — proactive token refresh
19
+ # 8. Auth injection — declarative or imperative (one or the other)
20
+ # 9. Adapter — sends the HTTP request
21
+ class ClientBuilder
22
+ DEFAULT_OPEN_TIMEOUT = 5
23
+ DEFAULT_TIMEOUT = 30
24
+
25
+ # `authenticate_config` is the connector class's declarative
26
+ # `authenticate type: :generic, properties: {...}` block (Phase 1). When
27
+ # present, AuthenticateGeneric middleware applies it. When absent, fall
28
+ # back to the imperative `auth_scheme` middleware (legacy path —
29
+ # `Auth::Scheme::ApiKey` / `Auth::Scheme::OAuth2`).
30
+ #
31
+ # `pre_auth_block` is the connector's `pre_authentication` block (Phase 3).
32
+ # When present, PreAuthentication middleware runs it before each request
33
+ # whose grant credentials look stale (expires_at missing or in the past).
34
+ def initialize(base_url:, grant:, auth_scheme:, authenticate_config: nil, pre_auth_block: nil,
35
+ open_timeout: DEFAULT_OPEN_TIMEOUT, timeout: DEFAULT_TIMEOUT)
36
+ @base_url = base_url
37
+ @grant = grant
38
+ @auth_scheme = auth_scheme
39
+ @authenticate_config = authenticate_config
40
+ @pre_auth_block = pre_auth_block
41
+ @open_timeout = open_timeout
42
+ @timeout = timeout
43
+ end
44
+
45
+ def build
46
+ grant = @grant
47
+ auth_scheme = @auth_scheme
48
+ authenticate_config = @authenticate_config
49
+ pre_auth_block = @pre_auth_block
50
+ open_timeout = @open_timeout
51
+ timeout = @timeout
52
+
53
+ Faraday.new(url: @base_url) do |f|
54
+ f.options.open_timeout = open_timeout
55
+ f.options.timeout = timeout
56
+
57
+ f.request :json
58
+ f.use Middleware::RateLimit, grant: grant
59
+ f.use Middleware::AutoRefresh, grant: grant
60
+ f.use Middleware::ErrorNormalization,
61
+ token_expired_status: grant.connector.class.oauth2_config&.fetch(:token_expired_status, 401) || 401
62
+ f.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
63
+ retry_statuses: [ 502, 503, 504 ],
64
+ methods: [ :get, :head, :options, :put, :delete ]
65
+ f.response :json, content_type: /\bjson\z/
66
+ f.use Middleware::GrantStatus, grant: grant
67
+
68
+ # PreAuthentication MUST sit before AuthenticateGeneric so the fresh
69
+ # credentials it writes to the grant are visible to the template
70
+ # resolver on the same request.
71
+ if pre_auth_block
72
+ f.use Middleware::PreAuthentication, grant: grant, pre_auth_block: pre_auth_block
73
+ end
74
+
75
+ if authenticate_config
76
+ f.use Middleware::AuthenticateGeneric,
77
+ grant: grant,
78
+ authenticate_config: authenticate_config
79
+ else
80
+ f.use auth_scheme, grant: grant
81
+ end
82
+
83
+ f.adapter Faraday.default_adapter
84
+ end
85
+ end
86
+ end
87
+ end