DhanHQ 2.3.0 → 2.4.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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module DhanHQ
6
+ module Auth
7
+ # Manages automatic token lifecycle including generation, renewal, and validation.
8
+ #
9
+ # TokenManager provides production-grade token automation by:
10
+ # - Generating new tokens via TOTP when needed
11
+ # - Renewing tokens before expiry (5-minute buffer)
12
+ # - Falling back to full generation if renewal fails
13
+ # - Thread-safe token operations via Monitor lock
14
+ #
15
+ # @example Enable auto token management
16
+ # client = DhanHQ::Client.new(api_type: :order_api)
17
+ # client.enable_auto_token_management!(
18
+ # dhan_client_id: ENV["DHAN_CLIENT_ID"],
19
+ # pin: ENV["DHAN_PIN"],
20
+ # totp_secret: ENV["DHAN_TOTP_SECRET"]
21
+ # )
22
+ #
23
+ # @example Manual usage
24
+ # manager = TokenManager.new(
25
+ # dhan_client_id: "123",
26
+ # pin: "1234",
27
+ # totp_secret: "SECRET"
28
+ # )
29
+ # manager.ensure_valid_token! # Auto-generates or renews as needed
30
+ #
31
+ # @see Auth::TokenGenerator for TOTP-based token generation
32
+ # @see Auth::TokenRenewal for token renewal logic
33
+ class TokenManager
34
+ def initialize(dhan_client_id:, pin:, totp_secret:)
35
+ @dhan_client_id = dhan_client_id
36
+ @pin = pin
37
+ @totp_secret = totp_secret
38
+
39
+ @token = nil
40
+ @lock = Monitor.new
41
+ end
42
+
43
+ def generate!
44
+ @lock.synchronize do
45
+ token = Auth::TokenGenerator.new.generate(
46
+ dhan_client_id: @dhan_client_id,
47
+ pin: @pin,
48
+ totp_secret: @totp_secret
49
+ )
50
+
51
+ apply_token(token)
52
+ end
53
+ end
54
+
55
+ def ensure_valid_token!
56
+ return generate! unless @token
57
+
58
+ return unless @token.needs_refresh?
59
+
60
+ refresh!
61
+ end
62
+
63
+ def refresh!
64
+ @lock.synchronize do
65
+ return generate! unless @token
66
+
67
+ renewal = Auth::TokenRenewal.new.renew
68
+ apply_token(renewal)
69
+ rescue Errors::AuthenticationError
70
+ generate!
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def apply_token(token)
77
+ @token = token
78
+
79
+ DhanHQ.configure do |config|
80
+ config.access_token = token.access_token
81
+ config.client_id = token.client_id if token.client_id.to_s.strip != ""
82
+ end
83
+
84
+ token
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Auth
5
+ # Backward-compatible wrapper for token renewal.
6
+ # Delegates to module-level Auth.renew_token.
7
+ class TokenRenewal
8
+ def renew
9
+ config = DhanHQ.configuration
10
+ raise Errors::AuthenticationError, "Missing configuration" unless config
11
+
12
+ access_token = config.resolved_access_token
13
+ dhan_client_id = config.client_id
14
+ raise Errors::AuthenticationError, "Missing dhanClientId (client_id)" if dhan_client_id.to_s.strip.empty?
15
+
16
+ response = Auth.renew_token(
17
+ access_token: access_token,
18
+ client_id: dhan_client_id
19
+ )
20
+
21
+ Models::TokenResponse.new(response)
22
+ end
23
+ end
24
+ end
25
+ end
data/lib/DhanHQ/auth.rb CHANGED
@@ -2,52 +2,112 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "rotp"
6
+ require_relative "errors"
5
7
 
6
8
  module DhanHQ
7
- # Helpers for Dhan authentication APIs.
8
- # The gem does not implement API key/secret consent flows or Partner consent;
9
- # use Dhan Web or your own OAuth flow to obtain tokens, then pass them via
10
- # configuration. This module supports refreshing web-generated tokens only.
9
+ # Module-level helpers for Dhan authentication APIs.
10
+ #
11
+ # Supports:
12
+ # - Generating access tokens via TOTP (for automated systems)
13
+ # - Renewing web-generated tokens (web-only tokens)
14
+ #
15
+ # @example Generate token with TOTP
16
+ # totp = DhanHQ::Auth.generate_totp(ENV["DHAN_TOTP_SECRET"])
17
+ # response = DhanHQ::Auth.generate_access_token(
18
+ # dhan_client_id: ENV["DHAN_CLIENT_ID"],
19
+ # pin: ENV["DHAN_PIN"],
20
+ # totp: totp
21
+ # )
22
+ # token = response["accessToken"]
23
+ #
24
+ # @example Renew web token
25
+ # response = DhanHQ::Auth.renew_token(
26
+ # access_token: current_token,
27
+ # client_id: ENV["DHAN_CLIENT_ID"]
28
+ # )
11
29
  module Auth
12
- # Refreshes a web-generated access token (24h validity).
13
- # Calls POST /v2/RenewToken; expires the current token and returns a new one.
14
- # Only valid for tokens generated from Dhan Web (not API key flow).
30
+ AUTH_BASE_URL = "https://auth.dhan.co"
31
+ API_BASE_URL = "https://api.dhan.co/v2"
32
+
33
+ # Generates an access token using TOTP authentication.
15
34
  #
16
- # @param access_token [String] Current JWT from Dhan Web
17
- # @param client_id [String] Dhan client ID (dhanClientId)
18
- # @param base_url [String, nil] API base URL (default: DhanHQ.configuration.base_url)
19
- # @return [HashWithIndifferentAccess] Response with :access_token and :expiry_time (if present)
20
- # @raise [DhanHQ::Error] On HTTP or API error
21
- def self.renew_token(access_token, client_id, base_url: nil)
22
- base_url ||= DhanHQ.configuration&.base_url || Configuration::BASE_URL
23
- url = "#{base_url.chomp("/")}/RenewToken"
24
-
25
- conn = Faraday.new(url: url) do |c|
26
- c.request :json
27
- c.response :json, content_type: /\bjson$/
28
- c.adapter Faraday.default_adapter
35
+ # POST https://auth.dhan.co/app/generateAccessToken
36
+ #
37
+ # @param dhan_client_id [String] Your Dhan client ID
38
+ # @param pin [String] Your 6-digit Dhan PIN
39
+ # @param totp [String] 6-digit TOTP code (or use generate_totp helper)
40
+ # @return [Hash] Response hash with accessToken, expiryTime, etc.
41
+ # @raise [DhanHQ::InvalidAuthenticationError] On authentication failure
42
+ def self.generate_access_token(dhan_client_id:, pin:, totp:)
43
+ conn = build_connection(AUTH_BASE_URL)
44
+
45
+ response = conn.post("/app/generateAccessToken") do |req|
46
+ req.headers["Accept"] = "application/json"
47
+
48
+ req.params = {
49
+ dhanClientId: dhan_client_id,
50
+ pin: pin,
51
+ totp: totp
52
+ }
29
53
  end
30
54
 
31
- response = conn.get("") do |req|
55
+ handle_response(response, context: "GenerateAccessToken")
56
+ end
57
+
58
+ # Renews a web-generated access token (24h validity).
59
+ #
60
+ # POST https://api.dhan.co/v2/RenewToken
61
+ #
62
+ # ⚠️ Works ONLY for tokens generated from Dhan Web dashboard.
63
+ # For TOTP-generated tokens, regenerate instead of renewing.
64
+ #
65
+ # @param access_token [String] Current JWT access token
66
+ # @param client_id [String] Dhan client ID (dhanClientId)
67
+ # @return [Hash] Response hash with new accessToken and expiryTime
68
+ # @raise [DhanHQ::InvalidAuthenticationError] On renewal failure
69
+ def self.renew_token(access_token:, client_id:)
70
+ conn = build_connection(API_BASE_URL)
71
+
72
+ response = conn.post("/RenewToken") do |req|
32
73
  req.headers["access-token"] = access_token
33
74
  req.headers["dhanClientId"] = client_id
34
75
  req.headers["Accept"] = "application/json"
35
76
  end
36
77
 
78
+ handle_response(response, context: "RenewToken")
79
+ end
80
+
81
+ # Generates a 6-digit TOTP code from a secret.
82
+ #
83
+ # @param secret [String] TOTP secret (from authenticator app setup)
84
+ # @return [String] 6-digit TOTP code
85
+ def self.generate_totp(secret)
86
+ ROTP::TOTP.new(secret).now
87
+ end
88
+
89
+ private
90
+
91
+ def self.build_connection(base_url)
92
+ Faraday.new(url: base_url) do |c|
93
+ c.request :url_encoded
94
+ c.response :json, content_type: /\bjson$/
95
+ c.adapter Faraday.default_adapter
96
+ end
97
+ end
98
+ private_class_method :build_connection
99
+
100
+ def self.handle_response(response, context:)
37
101
  unless response.success?
38
- body = begin
39
- JSON.parse(response.body.to_s)
40
- rescue JSON::ParserError
41
- {}
42
- end
102
+ body = response.body.is_a?(Hash) ? response.body : {}
43
103
  error_message = body["errorMessage"] || body["message"] || response.body.to_s
44
- raise DhanHQ::InvalidAuthenticationError, "RenewToken failed: #{response.status} #{error_message}"
104
+
105
+ raise DhanHQ::InvalidAuthenticationError,
106
+ "#{context} failed: #{response.status} #{error_message}"
45
107
  end
46
108
 
47
- data = response.body
48
- data = JSON.parse(data) if data.is_a?(String)
49
- result = data.is_a?(Hash) ? data : {}
50
- result.with_indifferent_access
109
+ response.body.is_a?(Hash) ? response.body : {}
51
110
  end
111
+ private_class_method :handle_response
52
112
  end
53
113
  end
data/lib/DhanHQ/client.rb CHANGED
@@ -29,6 +29,8 @@ module DhanHQ
29
29
  # @return [Faraday::Connection] The connection instance used for API requests.
30
30
  attr_reader :connection
31
31
 
32
+ attr_reader :token_manager
33
+
32
34
  # Initializes a new DhanHQ Client instance with a Faraday connection.
33
35
  #
34
36
  # @example Create a new client:
@@ -38,9 +40,9 @@ module DhanHQ
38
40
  # @return [DhanHQ::Client] A new client instance.
39
41
  # @raise [DhanHQ::Error] If configuration is invalid or rate limiter initialization fails
40
42
  def initialize(api_type:)
41
- # Configure from ENV if CLIENT_ID is present (backward compatible behavior)
43
+ # Configure from ENV if DHAN_CLIENT_ID is present (backward compatible behavior)
42
44
  # Validation happens at request time in build_headers, not here
43
- DhanHQ.configure_with_env if ENV.fetch("CLIENT_ID", nil)
45
+ DhanHQ.configure_with_env if ENV.fetch("DHAN_CLIENT_ID", nil)
44
46
 
45
47
  # Use shared rate limiter instance per API type to ensure proper coordination
46
48
  @rate_limiter = RateLimiter.for(api_type)
@@ -72,6 +74,7 @@ module DhanHQ
72
74
  # @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] Parsed JSON response.
73
75
  # @raise [DhanHQ::Error] If an HTTP error occurs.
74
76
  def request(method, path, payload, retries: 3)
77
+ @token_manager&.ensure_valid_token!
75
78
  @rate_limiter.throttle! # **Ensure we don't hit rate limit before calling API**
76
79
 
77
80
  attempt = 0
@@ -159,6 +162,43 @@ module DhanHQ
159
162
  request(:delete, path, params)
160
163
  end
161
164
 
165
+ def generate_access_token(dhan_client_id:, pin:, totp: nil, totp_secret: nil)
166
+ token = Auth::TokenGenerator.new.generate(
167
+ dhan_client_id: dhan_client_id,
168
+ pin: pin,
169
+ totp: totp,
170
+ totp_secret: totp_secret
171
+ )
172
+
173
+ DhanHQ.configure do |config|
174
+ config.access_token = token.access_token
175
+ config.client_id = token.client_id if token.client_id.to_s.strip != ""
176
+ end
177
+
178
+ token
179
+ end
180
+
181
+ def renew_access_token
182
+ token = Auth::TokenRenewal.new.renew
183
+
184
+ DhanHQ.configure do |config|
185
+ config.access_token = token.access_token
186
+ config.client_id = token.client_id if token.client_id.to_s.strip != ""
187
+ end
188
+
189
+ token
190
+ end
191
+
192
+ def enable_auto_token_management!(dhan_client_id:, pin:, totp_secret:)
193
+ @token_manager = Auth::TokenManager.new(
194
+ dhan_client_id: dhan_client_id,
195
+ pin: pin,
196
+ totp_secret: totp_secret
197
+ )
198
+
199
+ @token_manager.generate!
200
+ end
201
+
162
202
  private
163
203
 
164
204
  # Calculates exponential backoff time
@@ -98,8 +98,8 @@ module DhanHQ
98
98
  # config.client_id = "your_client_id"
99
99
  # config.access_token = "your_access_token"
100
100
  def initialize
101
- @client_id = ENV.fetch("CLIENT_ID", nil)
102
- @access_token = ENV.fetch("ACCESS_TOKEN", nil)
101
+ @client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
102
+ @access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
103
103
  @base_url = ENV.fetch("DHAN_BASE_URL", "https://api.dhan.co/v2")
104
104
  @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
105
105
  @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", "wss://api-order-update.dhan.co")
@@ -75,28 +75,5 @@ module DhanHQ
75
75
  end
76
76
  end
77
77
  end
78
-
79
- # Contract enforcing additional requirements for new order placement.
80
- class PlaceOrderContract < OrderContract
81
- # Additional placement specific rules
82
- rule(:after_market_order) do
83
- key.failure("amo_time required for after market orders") if value == true && !values[:amo_time]
84
- end
85
- end
86
-
87
- # Contract extending {OrderContract} for order modification payloads.
88
- class ModifyOrderContract < OrderContract
89
- # Modification specific requirements
90
- params do
91
- required(:order_id).filled(:string)
92
- optional(:quantity).maybe(:integer, gt?: 0)
93
- end
94
-
95
- rule do
96
- if !values[:price] && !values[:quantity] && !values[:trigger_price]
97
- key.failure("at least one modification field required")
98
- end
99
- end
100
- end
101
78
  end
102
79
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validation contract for trade-by-order-id requests.
6
+ class TradeByOrderIdContract < BaseContract
7
+ params do
8
+ required(:order_id).filled(:string, min_size?: 1)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
-
5
3
  module DhanHQ
6
4
  module Contracts
7
5
  ##
@@ -10,68 +8,5 @@ module DhanHQ
10
8
  # No input validation needed for GET requests
11
9
  # These contracts are mainly for documentation and future extensibility
12
10
  end
13
-
14
- ##
15
- # Validation contract for trade history requests
16
- class TradeHistoryContract < BaseContract
17
- params do
18
- required(:from_date).filled(:string)
19
- required(:to_date).filled(:string)
20
- optional(:page).filled(:integer, gteq?: 0)
21
- end
22
-
23
- rule(:from_date) do
24
- key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
25
- key.failure("must be a valid trading date (no weekend dates)") if valid_date_format?(value) && !trading_day?(Date.parse(value))
26
- end
27
-
28
- rule(:to_date) do
29
- key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
30
- end
31
-
32
- rule(:from_date, :to_date) do
33
- from_date_valid = valid_date_format?(values[:from_date])
34
- to_date_valid = valid_date_format?(values[:to_date])
35
-
36
- if values[:from_date] && values[:to_date] && from_date_valid && to_date_valid
37
- begin
38
- from_date = Date.parse(values[:from_date])
39
- to_date = Date.parse(values[:to_date])
40
-
41
- key.failure("from_date must be before to_date") if from_date >= to_date
42
- rescue Date::Error
43
- key.failure("invalid date format")
44
- end
45
- end
46
- end
47
-
48
- private
49
-
50
- def valid_date_format?(date_string)
51
- return false unless date_string.is_a?(String)
52
- return false unless date_string.match?(/\A\d{4}-\d{2}-\d{2}\z/)
53
-
54
- begin
55
- Date.parse(date_string)
56
- true
57
- rescue Date::Error
58
- false
59
- end
60
- end
61
-
62
- def trading_day?(date)
63
- return false unless date.is_a?(Date)
64
-
65
- (1..5).cover?(date.wday)
66
- end
67
- end
68
-
69
- ##
70
- # Validation contract for trade by order ID requests
71
- class TradeByOrderIdContract < BaseContract
72
- params do
73
- required(:order_id).filled(:string, min_size?: 1)
74
- end
75
- end
76
11
  end
77
12
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validation contract for trade history requests.
8
+ class TradeHistoryContract < BaseContract
9
+ params do
10
+ required(:from_date).filled(:string)
11
+ required(:to_date).filled(:string)
12
+ optional(:page).filled(:integer, gteq?: 0)
13
+ end
14
+
15
+ rule(:from_date) do
16
+ key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
17
+ next unless valid_date_format?(value)
18
+
19
+ key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(Date.parse(value))
20
+ end
21
+
22
+ rule(:to_date) do
23
+ key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
24
+ end
25
+
26
+ rule(:from_date, :to_date) do
27
+ from = values[:from_date]
28
+ to = values[:to_date]
29
+ next unless valid_date_format?(from) && valid_date_format?(to)
30
+
31
+ key.failure("from_date must be before to_date") if Date.parse(from) >= Date.parse(to)
32
+ rescue Date::Error
33
+ key.failure("invalid date format")
34
+ end
35
+
36
+ private
37
+
38
+ def valid_date_format?(date_string)
39
+ return false unless date_string.is_a?(String) && date_string.match?(/\A\d{4}-\d{2}-\d{2}\z/)
40
+
41
+ Date.parse(date_string)
42
+ true
43
+ rescue Date::Error
44
+ false
45
+ end
46
+
47
+ def trading_day?(date)
48
+ date.is_a?(Date) && (1..5).cover?(date.wday)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module DhanHQ
6
+ # Faraday client for Dhan auth endpoints.
7
+ #
8
+ # This class intentionally lives at the top-level namespace so it autoloads
9
+ # cleanly from `lib/DhanHQ/core/auth_api.rb` with Zeitwerk `collapse`.
10
+ class AuthAPI
11
+ BASE_URL = "https://auth.dhan.co"
12
+
13
+ def connection
14
+ @connection ||= Faraday.new(url: BASE_URL) do |faraday|
15
+ faraday.request :url_encoded
16
+ faraday.response :json, content_type: /\bjson$/
17
+ faraday.adapter Faraday.default_adapter
18
+ end
19
+ end
20
+ end
21
+ end
@@ -44,7 +44,7 @@ module DhanHQ
44
44
  client_id = DhanHQ.configuration&.client_id
45
45
  unless client_id
46
46
  raise DhanHQ::InvalidAuthenticationError,
47
- "client_id is required for DATA APIs but not set. Please configure DhanHQ with CLIENT_ID."
47
+ "client_id is required for DATA APIs but not set. Please configure DhanHQ with DHAN_CLIENT_ID."
48
48
  end
49
49
  headers["client-id"] = client_id
50
50
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module DhanHQ
6
+ module Models
7
+ # Represents a Dhan API token response with expiry tracking and validation.
8
+ #
9
+ # TokenResponse wraps the response from Dhan's token generation and renewal
10
+ # endpoints, providing convenient methods for checking token validity and
11
+ # determining when refresh is needed.
12
+ #
13
+ # @example From token generation
14
+ # response = Auth.generate_access_token(
15
+ # dhan_client_id: "123",
16
+ # pin: "1234",
17
+ # totp: "654321"
18
+ # )
19
+ # token = TokenResponse.new(response)
20
+ # token.expired? # => false
21
+ # token.expires_in # => 86400 (seconds)
22
+ # token.needs_refresh? # => false
23
+ #
24
+ # @example Checking token status
25
+ # if token.needs_refresh?(buffer_seconds: 600)
26
+ # # Refresh token 10 minutes before expiry
27
+ # new_token = Auth.renew_token(...)
28
+ # end
29
+ #
30
+ # @attr_reader [String] client_id Dhan client ID
31
+ # @attr_reader [String] client_name Dhan client name
32
+ # @attr_reader [String] ucc Unique client code
33
+ # @attr_reader [Boolean] power_of_attorney POA status
34
+ # @attr_reader [String] access_token The authentication token
35
+ # @attr_reader [Time] expiry_time Token expiration timestamp
36
+ class TokenResponse
37
+ attr_reader :client_id,
38
+ :client_name,
39
+ :ucc,
40
+ :power_of_attorney,
41
+ :access_token,
42
+ :expiry_time
43
+
44
+ def initialize(data)
45
+ data = normalize_keys(data)
46
+
47
+ @client_id = data["dhanClientId"]
48
+ @client_name = data["dhanClientName"]
49
+ @ucc = data["dhanClientUcc"]
50
+ @power_of_attorney = data["givenPowerOfAttorney"]
51
+ @access_token = data["accessToken"]
52
+ @expiry_time = parse_time(data["expiryTime"])
53
+ end
54
+
55
+ def expired?
56
+ return true unless expiry_time
57
+
58
+ Time.now >= expiry_time
59
+ end
60
+
61
+ def expires_in
62
+ return 0 unless expiry_time
63
+
64
+ expiry_time - Time.now
65
+ end
66
+
67
+ def needs_refresh?(buffer_seconds: 300)
68
+ return true unless expiry_time
69
+
70
+ Time.now >= (expiry_time - buffer_seconds)
71
+ end
72
+
73
+ private
74
+
75
+ def normalize_keys(data)
76
+ return {} unless data.is_a?(Hash)
77
+
78
+ data.transform_keys(&:to_s)
79
+ end
80
+
81
+ def parse_time(value)
82
+ return nil if value.nil? || value.to_s.strip.empty?
83
+
84
+ Time.parse(value.to_s)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "2.3.0"
5
+ VERSION = "2.4.0"
6
6
  end