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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sudhanva
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sudhanva.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sudhanva/version"
4
+ require_relative "sudhanva/client"
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: []