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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/CODE_REVIEW_ISSUES.md +2 -2
- data/GUIDE.md +2 -2
- data/README.md +15 -4
- data/README1.md +4 -4
- data/REVIEW_SUMMARY.md +2 -2
- data/docs/AUTHENTICATION.md +54 -2
- data/docs/TESTING_GUIDE.md +8 -8
- data/docs/live_order_updates.md +2 -2
- data/docs/rails_integration.md +7 -7
- data/docs/standalone_ruby_websocket_integration.md +24 -24
- data/docs/technical_analysis.md +1 -1
- data/docs/websocket_integration.md +4 -4
- data/examples/comprehensive_websocket_examples.rb +2 -2
- data/examples/instrument_finder_test.rb +2 -2
- data/examples/market_depth_example.rb +2 -2
- data/examples/market_feed_example.rb +2 -2
- data/examples/order_update_example.rb +2 -2
- data/examples/trading_fields_example.rb +2 -2
- data/lib/DhanHQ/auth/token_generator.rb +33 -0
- data/lib/DhanHQ/auth/token_manager.rb +88 -0
- data/lib/DhanHQ/auth/token_renewal.rb +25 -0
- data/lib/DhanHQ/auth.rb +91 -31
- data/lib/DhanHQ/client.rb +42 -2
- data/lib/DhanHQ/configuration.rb +2 -2
- data/lib/DhanHQ/contracts/order_contract.rb +0 -23
- data/lib/DhanHQ/contracts/trade_by_order_id_contract.rb +12 -0
- data/lib/DhanHQ/contracts/trade_contract.rb +0 -65
- data/lib/DhanHQ/contracts/trade_history_contract.rb +52 -0
- data/lib/DhanHQ/core/auth_api.rb +21 -0
- data/lib/DhanHQ/helpers/request_helper.rb +1 -1
- data/lib/DhanHQ/models/token_response.rb +88 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/dhan_hq.rb +31 -81
- metadata +37 -3
- data/lib/DhanHQ/config.rb +0 -33
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# @param
|
|
19
|
-
# @
|
|
20
|
-
# @
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
104
|
+
|
|
105
|
+
raise DhanHQ::InvalidAuthenticationError,
|
|
106
|
+
"#{context} failed: #{response.status} #{error_message}"
|
|
45
107
|
end
|
|
46
108
|
|
|
47
|
-
|
|
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
|
|
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("
|
|
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
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -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("
|
|
102
|
-
@access_token = ENV.fetch("
|
|
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
|
|
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
|
data/lib/DhanHQ/version.rb
CHANGED