sudhanva 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +68 -0
- data/lib/sudhanva/client.rb +155 -0
- data/lib/sudhanva/version.rb +5 -0
- data/lib/sudhanva.rb +4 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bbe82330602c646a93171c209c60239fc9ada05a2f6ec12595f0f18c8daff762
|
|
4
|
+
data.tar.gz: 8b1a707ca41396832b107df893fa08032832663b63610a4c9fc26ed16f306960
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d2c6bcddb7efe2d5671a28ca756f6cd0dc4e72c534bd61266ae1aebdca98e2e9628814fd534483f78b0aba3bba111d8873a1852232c4752bd4c88ce7c62e264b
|
|
7
|
+
data.tar.gz: 5edcb475d5f20bca97689cf312afa7242818d17e0858f6af9da75c21c2482cda3df7fc51a6eaab752cfbf55531d0f25d3b954699392f1a44e3f697699ead225d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sudhanva Narayana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# sudhanva for Ruby
|
|
2
|
+
|
|
3
|
+
Minimal, dependency-free Ruby client for the public
|
|
4
|
+
[sudhanva.me API](https://sudhanva.me/openapi.json). It retrieves published profile and article
|
|
5
|
+
metadata, performs bounded batch reads, searches the published site, and creates or polls temporary
|
|
6
|
+
profile-insight jobs.
|
|
7
|
+
|
|
8
|
+
The API is public and requires no credentials. Do not send private data.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install sudhanva
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or add it to a `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "sudhanva", "~> 0.1"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "sudhanva"
|
|
26
|
+
|
|
27
|
+
client = Sudhanva::Client.new
|
|
28
|
+
|
|
29
|
+
profile = client.profile
|
|
30
|
+
posts = client.posts(limit: 5, tag: "kubernetes")
|
|
31
|
+
article = client.post("making-your-site-agent-friendly")
|
|
32
|
+
|
|
33
|
+
job = client.create_profile_insight(
|
|
34
|
+
audience: "hiring-manager",
|
|
35
|
+
focus: ["production-ml", "inference"],
|
|
36
|
+
idempotency_key: "my-workflow-2026-08-23"
|
|
37
|
+
)
|
|
38
|
+
result = client.wait_for_profile_insight(job["job_id"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
All methods return decoded JSON hashes. Non-success responses raise `Sudhanva::APIError` with
|
|
42
|
+
`status`, `code`, and the decoded response body.
|
|
43
|
+
|
|
44
|
+
## API coverage
|
|
45
|
+
|
|
46
|
+
- `profile`
|
|
47
|
+
- `posts` and `post`
|
|
48
|
+
- `batch`
|
|
49
|
+
- `create_profile_insight`, `profile_insight`, and `wait_for_profile_insight`
|
|
50
|
+
- `ask` for NLWeb conversational search
|
|
51
|
+
|
|
52
|
+
The client follows the stable `/api/v1` contract. See the
|
|
53
|
+
[developer documentation](https://sudhanva.me/developers/sdks/) and
|
|
54
|
+
[versioning policy](https://sudhanva.me/developers/versioning/).
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bundle install
|
|
60
|
+
bundle exec rake test
|
|
61
|
+
gem build sudhanva.gemspec
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The test suite uses an injected transport and never calls production.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Sudhanva
|
|
8
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
class APIError < StandardError
|
|
11
|
+
attr_reader :status, :code, :body
|
|
12
|
+
|
|
13
|
+
def initialize(status:, code:, message:, body:)
|
|
14
|
+
super("#{status} #{code}: #{message}")
|
|
15
|
+
@status = status
|
|
16
|
+
@code = code
|
|
17
|
+
@body = body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Client
|
|
22
|
+
DEFAULT_BASE_URL = "https://sudhanva.me/api/v1"
|
|
23
|
+
USER_AGENT = "sudhanva-ruby/#{VERSION}"
|
|
24
|
+
|
|
25
|
+
def initialize(base_url: DEFAULT_BASE_URL, timeout: 10, transport: nil)
|
|
26
|
+
raise ArgumentError, "timeout must be positive" unless timeout.positive?
|
|
27
|
+
|
|
28
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
29
|
+
parsed = URI.parse(@base_url)
|
|
30
|
+
unless %w[http https].include?(parsed.scheme) && parsed.host
|
|
31
|
+
raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@site_url = "#{parsed.scheme}://#{parsed.host}#{":#{parsed.port}" unless parsed.default_port == parsed.port}"
|
|
35
|
+
@timeout = timeout
|
|
36
|
+
@transport = transport || method(:default_transport)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def profile(locale: "en")
|
|
40
|
+
request("GET", "/profile", query: { locale: locale })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def posts(limit: 20, tag: nil, cursor: nil)
|
|
44
|
+
raise ArgumentError, "limit must be between 1 and 100" unless (1..100).cover?(limit)
|
|
45
|
+
|
|
46
|
+
request("GET", "/posts", query: { limit: limit, tag: tag, cursor: cursor })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def post(slug)
|
|
50
|
+
raise ArgumentError, "slug is required" if slug.to_s.empty?
|
|
51
|
+
|
|
52
|
+
request("GET", "/posts/#{URI.encode_www_form_component(slug)}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def batch(operations)
|
|
56
|
+
raise ArgumentError, "operations must contain between 1 and 20 items" unless (1..20).cover?(operations.length)
|
|
57
|
+
|
|
58
|
+
request("POST", "/batch", json: { operations: operations })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_profile_insight(audience:, idempotency_key:, focus: nil)
|
|
62
|
+
raise ArgumentError, "idempotency_key is required" if idempotency_key.to_s.empty?
|
|
63
|
+
|
|
64
|
+
payload = { audience: audience }
|
|
65
|
+
payload[:focus] = focus if focus
|
|
66
|
+
request(
|
|
67
|
+
"POST",
|
|
68
|
+
"/profile-insights",
|
|
69
|
+
headers: { "Idempotency-Key" => idempotency_key },
|
|
70
|
+
json: payload
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def profile_insight(job_id)
|
|
75
|
+
raise ArgumentError, "job_id is required" if job_id.to_s.empty?
|
|
76
|
+
|
|
77
|
+
request("GET", "/profile-insights/#{URI.encode_www_form_component(job_id)}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def wait_for_profile_insight(job_id, timeout: 30, poll_interval: 1)
|
|
81
|
+
unless timeout.positive? && poll_interval >= 0
|
|
82
|
+
raise ArgumentError, "timeout must be positive and poll_interval cannot be negative"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
86
|
+
loop do
|
|
87
|
+
job = profile_insight(job_id)
|
|
88
|
+
return job if %w[succeeded failed].include?(job["status"])
|
|
89
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
90
|
+
raise Timeout::Error, "profile insight #{job_id} did not finish within #{timeout}s"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sleep(poll_interval)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def ask(text, limit: 10, mode: "list")
|
|
98
|
+
raise ArgumentError, "text is required" if text.to_s.empty?
|
|
99
|
+
raise ArgumentError, "limit must be between 1 and 20" unless (1..20).cover?(limit)
|
|
100
|
+
|
|
101
|
+
request(
|
|
102
|
+
"POST",
|
|
103
|
+
"/ask",
|
|
104
|
+
base: @site_url,
|
|
105
|
+
json: {
|
|
106
|
+
query: { text: text, site: @site_url, limit: limit },
|
|
107
|
+
prefer: { streaming: false, response_format: "conversational_search", mode: mode },
|
|
108
|
+
meta: { version: "0.55" }
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def request(method, path, query: {}, headers: {}, json: nil, base: @base_url)
|
|
116
|
+
uri = URI.parse("#{base.sub(%r{/+\z}, "")}/#{path.sub(%r{\A/+}, "")}")
|
|
117
|
+
query = query.reject { |_, value| value.nil? }
|
|
118
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
119
|
+
|
|
120
|
+
request_headers = { "Accept" => "application/json", "User-Agent" => USER_AGENT }.merge(headers)
|
|
121
|
+
body = nil
|
|
122
|
+
if json
|
|
123
|
+
request_headers["Content-Type"] = "application/json"
|
|
124
|
+
body = JSON.generate(json)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
response = @transport.call(method, uri, request_headers, body, @timeout)
|
|
128
|
+
payload = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
129
|
+
unless response.status.between?(200, 299)
|
|
130
|
+
error = payload.fetch("error", payload)
|
|
131
|
+
code = error.fetch("code", payload.fetch("code", "api_error"))
|
|
132
|
+
message = error.fetch("message", payload.fetch("message", "Request failed"))
|
|
133
|
+
raise APIError.new(status: response.status, code: code, message: message, body: payload)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
raise APIError.new(status: response.status, code: "invalid_response", message: "API returned a non-object response", body: payload) unless payload.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
payload
|
|
139
|
+
rescue JSON::ParserError => error
|
|
140
|
+
raise APIError.new(status: response&.status || 0, code: "invalid_response", message: "API returned invalid JSON", body: nil), cause: error
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def default_transport(method, uri, headers, body, timeout)
|
|
144
|
+
request_class = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
|
|
145
|
+
http_request = request_class.new(uri, headers)
|
|
146
|
+
http_request.body = body if body
|
|
147
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
148
|
+
http.use_ssl = uri.scheme == "https"
|
|
149
|
+
http.open_timeout = timeout
|
|
150
|
+
http.read_timeout = timeout
|
|
151
|
+
result = http.request(http_request)
|
|
152
|
+
Response.new(status: result.code.to_i, headers: result.to_hash, body: result.body.to_s)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/sudhanva.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sudhanva
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sudhanva Narayana
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A dependency-free client for published profile data, articles, search,
|
|
14
|
+
batch reads, and profile-insight jobs.
|
|
15
|
+
email:
|
|
16
|
+
- nsudhanva@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/sudhanva.rb
|
|
24
|
+
- lib/sudhanva/client.rb
|
|
25
|
+
- lib/sudhanva/version.rb
|
|
26
|
+
homepage: https://sudhanva.me
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
homepage_uri: https://sudhanva.me
|
|
31
|
+
source_code_uri: https://github.com/nsudhanva/sudhanva-ruby
|
|
32
|
+
documentation_uri: https://sudhanva.me/developers/sdks/
|
|
33
|
+
bug_tracker_uri: https://github.com/nsudhanva/sudhanva-ruby/issues
|
|
34
|
+
rubygems_mfa_required: 'true'
|
|
35
|
+
post_install_message:
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '3.1'
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.0.3.1
|
|
51
|
+
signing_key:
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: Minimal Ruby client for the public sudhanva.me API.
|
|
54
|
+
test_files: []
|