From a9ad1c8195bc2b2efc062dccb02641df37b9a085 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:11:07 +1100 Subject: [PATCH 1/4] sprint 1 first pass --- .github/copilot-instructions.md | 8 ++ .gitignore | 2 + Gemfile.lock | 116 ++++++++++++++++++ lib/novacloud_client.rb | 11 +- lib/novacloud_client/client.rb | 77 ++++++++++++ lib/novacloud_client/configuration.rb | 31 +++++ lib/novacloud_client/errors.rb | 26 ++++ .../middleware/authentication.rb | 49 ++++++++ .../middleware/error_handler.rb | 66 ++++++++++ novacloud_client.gemspec | 25 ++-- 10 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 Gemfile.lock create mode 100644 lib/novacloud_client/client.rb create mode 100644 lib/novacloud_client/configuration.rb create mode 100644 lib/novacloud_client/errors.rb create mode 100644 lib/novacloud_client/middleware/authentication.rb create mode 100644 lib/novacloud_client/middleware/error_handler.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..2ba38f5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# GitHub Copilot Instructions + +- CAREFULLY read all instructions before coding. +- Spend as much time as needed to analyze and understand the context. +- Check online references/documentation if applicable. +- https://developer-en.vnnox.com/ +- API example response also availble in the docs +- ALWAYS follow best practices and coding standards for public ruby gems. diff --git a/.gitignore b/.gitignore index b04a8c8..9b45c20 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ # rspec failure tracking .rspec_status + +/docs/internal diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..e924e9f --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,116 @@ +PATH + remote: . + specs: + novacloud_client (0.1.0) + faraday (~> 2.7) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + bigdecimal (3.3.1) + crack (1.0.1) + bigdecimal + rexml + date (3.4.1) + diff-lcs (1.6.2) + erb (5.1.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.1) + net-http (>= 0.5.0) + hashdiff (1.2.1) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.15.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + net-http (0.6.0) + uri + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.15.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.6) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + rubocop (1.81.6) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + stringio (3.1.7) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.0.4) + webmock (3.25.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + yard (0.9.37) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + bundler (~> 2.4) + irb + novacloud_client! + rake (~> 13.0) + rspec (~> 3.12, ~> 3.0) + rubocop (~> 1.21) + webmock (~> 3.19) + yard (~> 0.9) + +BUNDLED WITH + 2.7.1 diff --git a/lib/novacloud_client.rb b/lib/novacloud_client.rb index 88186cd..e8a55d7 100644 --- a/lib/novacloud_client.rb +++ b/lib/novacloud_client.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -require_relative "novacloud_client/version" +require 'faraday' +require 'json' -module NovacloudClient - class Error < StandardError; end - # Your code goes here... -end +require_relative 'novacloud_client/version' +require_relative 'novacloud_client/errors' +require_relative 'novacloud_client/configuration' +require_relative 'novacloud_client/client' diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb new file mode 100644 index 0000000..5f9604d --- /dev/null +++ b/lib/novacloud_client/client.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'faraday' +require 'json' + +require_relative 'errors' +require_relative 'configuration' +require_relative 'middleware/authentication' +require_relative 'middleware/error_handler' + +module NovacloudClient + # Central entry point for interacting with the NovaCloud API. + class Client + attr_reader :config + + def initialize(app_key:, app_secret:, service_domain:, &faraday_block) + @config = Configuration.new + @config.app_key = app_key + @config.app_secret = app_secret + @config.service_domain = service_domain + @config.validate! + + @faraday_block = faraday_block + end + + def connection + @connection ||= build_connection + end + + def request(http_method:, endpoint:, params: {}) + symbolized_method = http_method.to_sym + response = connection.public_send(symbolized_method) do |req| + req.url endpoint + + case symbolized_method + when :get, :delete + req.params.update(params) unless params.empty? + else + unless params.empty? + req.headers['Content-Type'] = 'application/json; charset=utf-8' + req.body = JSON.generate(params) + end + end + end + + parse_body(response) + end + + private + + def build_connection + Faraday.new(url: config.base_url) do |faraday| + faraday.headers['Accept'] = 'application/json' + + faraday.use Middleware::Authentication, + app_key: config.app_key, + app_secret: config.app_secret + faraday.use Middleware::ErrorHandler + + @faraday_block&.call(faraday) + + faraday.adapter config.adapter + end + end + + def parse_body(response) + body = response.body + return nil if body.nil? + return body unless body.is_a?(String) + return nil if body.strip.empty? + + JSON.parse(body) + rescue JSON::ParserError + body + end + end +end diff --git a/lib/novacloud_client/configuration.rb b/lib/novacloud_client/configuration.rb new file mode 100644 index 0000000..d9c7b06 --- /dev/null +++ b/lib/novacloud_client/configuration.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module NovacloudClient + # Holds configuration for a NovacloudClient::Client instance. + class Configuration + attr_accessor :app_key, :app_secret, :service_domain, :adapter + + def initialize + @adapter = :net_http + end + + def base_url + "https://#{service_domain}" + end + + def validate! + raise ArgumentError, "app_key is required" if blank?(app_key) + raise ArgumentError, "app_secret is required" if blank?(app_secret) + raise ArgumentError, "service_domain is required" if blank?(service_domain) + end + + private + + def blank?(value) + return true if value.nil? + return value.strip.empty? if value.is_a?(String) + + value.respond_to?(:empty?) ? value.empty? : false + end + end +end diff --git a/lib/novacloud_client/errors.rb b/lib/novacloud_client/errors.rb new file mode 100644 index 0000000..390b178 --- /dev/null +++ b/lib/novacloud_client/errors.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module NovacloudClient + # Base error class allowing access to the Faraday environment. + class Error < StandardError + attr_reader :response + + def initialize(message = nil, response: nil) + super(message) + @response = response + end + end + + class ClientError < Error; end + class BadRequestError < ClientError; end + class AuthenticationError < ClientError; end + class PermissionError < ClientError; end + class NotAcceptableError < ClientError; end + class RateLimitError < ClientError; end + + class ServerError < Error; end + class InternalServerError < ServerError; end + class BadGatewayError < ServerError; end + class ServiceUnavailableError < ServerError; end + class GatewayTimeoutError < ServerError; end +end diff --git a/lib/novacloud_client/middleware/authentication.rb b/lib/novacloud_client/middleware/authentication.rb new file mode 100644 index 0000000..f28a65f --- /dev/null +++ b/lib/novacloud_client/middleware/authentication.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'faraday' +require 'digest' +require 'securerandom' + +module NovacloudClient + module Middleware + # Injects the NovaCloud authentication headers into every request. + class Authentication < Faraday::Middleware + def initialize(app, app_key:, app_secret:) + super(app) + @app_key = app_key + @app_secret = app_secret + end + + def call(env) + cur_time = current_utc_timestamp + nonce = generate_nonce + checksum = checksum_for(nonce, cur_time) + + headers = env.request_headers + headers['AppKey'] = @app_key + headers['Nonce'] = nonce + headers['CurTime'] = cur_time + headers['CheckSum'] = checksum + + @app.call(env) + end + + private + + def current_utc_timestamp + Time.now.utc.to_i.to_s + end + + def generate_nonce + timestamp_component = (Time.now.utc.to_f * 1_000_000).to_i.to_s(36) + random_component = SecureRandom.alphanumeric(16) + (timestamp_component + random_component)[0, 16] + end + + def checksum_for(nonce, cur_time) + signature = @app_secret + nonce + cur_time + Digest::SHA256.hexdigest(signature) + end + end + end +end diff --git a/lib/novacloud_client/middleware/error_handler.rb b/lib/novacloud_client/middleware/error_handler.rb new file mode 100644 index 0000000..45b5eb6 --- /dev/null +++ b/lib/novacloud_client/middleware/error_handler.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'faraday' + +require_relative '../errors' + +module NovacloudClient + module Middleware + # Maps HTTP error responses to NovacloudClient exception classes. + class ErrorHandler < Faraday::Middleware + ERROR_MAP = { + 400 => NovacloudClient::BadRequestError, + 401 => NovacloudClient::AuthenticationError, + 403 => NovacloudClient::PermissionError, + 406 => NovacloudClient::NotAcceptableError, + 429 => NovacloudClient::RateLimitError, + 500 => NovacloudClient::InternalServerError, + 502 => NovacloudClient::BadGatewayError, + 503 => NovacloudClient::ServiceUnavailableError, + 504 => NovacloudClient::GatewayTimeoutError + }.freeze + + def initialize(app) + super(app) + end + + def call(env) + @app.call(env).on_complete do |response_env| + handle_response(response_env) + end + end + + private + + def handle_response(env) + status = env.status.to_i + return if (200..299).cover?(status) + + error_class = ERROR_MAP[status] || fallback_error(status) + message = "HTTP #{status}: #{summary_from(env)}" + raise error_class.new(message, response: env) + end + + def fallback_error(status) + if (400..499).cover?(status) + NovacloudClient::ClientError + else + NovacloudClient::ServerError + end + end + + def summary_from(env) + body = env.body + return 'No response body' if body.nil? + + if body.is_a?(String) + body.strip.empty? ? 'Empty body' : body.strip[0, 200] + elsif body.respond_to?(:to_json) + body.to_json[0, 200] + else + body.to_s[0, 200] + end + end + end + end +end diff --git a/novacloud_client.gemspec b/novacloud_client.gemspec index 438e0c8..5a91bf7 100644 --- a/novacloud_client.gemspec +++ b/novacloud_client.gemspec @@ -8,16 +8,14 @@ Gem::Specification.new do |spec| spec.authors = ["Chayut Orapinpatipat"] spec.email = ["chayut@canopusnet.com"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Ruby client for the NovaCloud API." + spec.description = "A Faraday-based Ruby client for the NovaCloud Open Platform that handles authentication, error mapping, and resource abstractions." + spec.homepage = "https://github.com/canopusnet/novacloud_client" spec.license = "MIT" - spec.required_ruby_version = ">= 3.2.0" - - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + spec.required_ruby_version = ">= 2.7.0" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "https://github.com/canopusnet/novacloud_client/blob/main/CHANGELOG.md" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -28,12 +26,19 @@ Gem::Specification.new do |spec| f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .github/ .rubocop.yml]) end end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.bindir = "bin" + spec.executables = [] spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" + spec.add_runtime_dependency "faraday", "~> 2.7" + + spec.add_development_dependency "bundler", "~> 2.4" + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.12" + spec.add_development_dependency "webmock", "~> 3.19" + spec.add_development_dependency "yard", "~> 0.9" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html From 07b2cd886b2ce3e0dad979bde475ba5e4bef7c14 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:16:18 +1100 Subject: [PATCH 2/4] spec 1 --- spec/novacloud_client/client_spec.rb | 145 ++++++++++++++++++ .../middleware/authentication_spec.rb | 41 +++++ .../middleware/error_handler_spec.rb | 40 +++++ spec/novacloud_client_spec.rb | 5 +- spec/spec_helper.rb | 3 + 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 spec/novacloud_client/client_spec.rb create mode 100644 spec/novacloud_client/middleware/authentication_spec.rb create mode 100644 spec/novacloud_client/middleware/error_handler_spec.rb diff --git a/spec/novacloud_client/client_spec.rb b/spec/novacloud_client/client_spec.rb new file mode 100644 index 0000000..dae8e06 --- /dev/null +++ b/spec/novacloud_client/client_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "spec_helper" +require "digest" +require "json" + +RSpec.describe NovacloudClient::Client do + let(:app_key) { "app_key" } + let(:app_secret) { "app_secret" } + let(:service_domain) { "open-us.vnnox.com" } + let(:client) { described_class.new(app_key: app_key, app_secret: app_secret, service_domain: service_domain) } + let(:fixed_time) { Time.utc(2023, 1, 1, 0, 0, 0) } + + describe "initialization" do + it "requires an app_key" do + expect do + described_class.new(app_key: "", app_secret: "secret", service_domain: "domain") + end.to raise_error(ArgumentError, /app_key is required/) + end + + it "requires an app_secret" do + expect do + described_class.new(app_key: "key", app_secret: nil, service_domain: "domain") + end.to raise_error(ArgumentError, /app_secret is required/) + end + + it "requires a service_domain" do + expect do + described_class.new(app_key: "key", app_secret: "secret", service_domain: "") + end.to raise_error(ArgumentError, /service_domain is required/) + end + end + + describe "#request" do + before do + allow(Time).to receive(:now).and_return(fixed_time) + end + + it "performs a GET request with query params and authentication headers" do + captured_headers = nil + + stub_request(:get, "https://open-us.vnnox.com/v2/player/list") + .with(query: hash_including("pageNum" => "1", "pageSize" => "10")) + .to_return do |request| + captured_headers = request.headers + { + status: 200, + body: { "code" => 0 }.to_json, + headers: { "Content-Type" => "application/json" } + } + end + + response = client.request( + http_method: :get, + endpoint: "/v2/player/list", + params: { pageNum: 1, pageSize: 10 } + ) + + expect(response).to eq("code" => 0) + normalized_headers = captured_headers.transform_keys { |key| key.to_s.downcase } + expect(normalized_headers["appkey"]).to eq(app_key) + expect(normalized_headers["curtime"]).to eq(fixed_time.to_i.to_s) + expect(normalized_headers["accept"]).to eq("application/json") + nonce = normalized_headers["nonce"] + checksum = Digest::SHA256.hexdigest(app_secret + nonce + normalized_headers["curtime"]) + expect(normalized_headers["checksum"]).to eq(checksum) + end + + it "serializes POST params as JSON" do + captured_body = nil + + stub_request(:post, "https://open-us.vnnox.com/v2/player/program") + .to_return do |request| + captured_body = request.body + { + status: 200, + body: { "success" => true }.to_json, + headers: { "Content-Type" => "application/json" } + } + end + + response = client.request( + http_method: :post, + endpoint: "/v2/player/program", + params: { playerIds: [1, 2], brightness: 80 } + ) + + expect(JSON.parse(captured_body)).to eq("playerIds" => [1, 2], "brightness" => 80) + expect(response).to eq("success" => true) + end + + it "raises specific errors from middleware" do + stub_request(:get, "https://open-us.vnnox.com/v2/player/list") + .to_return(status: 401, body: { "code" => 401 }.to_json, headers: { "Content-Type" => "application/json" }) + + expect do + client.request(http_method: :get, endpoint: "/v2/player/list") + end.to raise_error(NovacloudClient::AuthenticationError) + end + + it "allows middleware customization via block" do + custom_client = described_class.new( + app_key: app_key, + app_secret: app_secret, + service_domain: service_domain + ) do |faraday| + faraday.headers["X-Custom-Header"] = "custom" + end + + custom_header = nil + + stub_request(:get, "https://open-us.vnnox.com/v2/player/list") + .to_return do |request| + custom_header = request.headers["X-Custom-Header"] + { + status: 200, + body: { "ok" => true }.to_json, + headers: { "Content-Type" => "application/json" } + } + end + + custom_client.request(http_method: :get, endpoint: "/v2/player/list") + + expect(custom_header).to eq("custom") + end + + it "returns nil for empty bodies" do + stub_request(:get, "https://open-us.vnnox.com/v2/player/list") + .to_return(status: 200, body: "", headers: {}) + + response = client.request(http_method: :get, endpoint: "/v2/player/list") + + expect(response).to be_nil + end + + it "returns raw body when JSON parsing fails" do + stub_request(:get, "https://open-us.vnnox.com/v2/player/list") + .to_return(status: 200, body: "not-json", headers: { "Content-Type" => "text/plain" }) + + response = client.request(http_method: :get, endpoint: "/v2/player/list") + + expect(response).to eq("not-json") + end + end +end diff --git a/spec/novacloud_client/middleware/authentication_spec.rb b/spec/novacloud_client/middleware/authentication_spec.rb new file mode 100644 index 0000000..6d0b899 --- /dev/null +++ b/spec/novacloud_client/middleware/authentication_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "spec_helper" +require "digest" + +RSpec.describe NovacloudClient::Middleware::Authentication do + let(:app) { ->(env) { env } } + let(:middleware) { described_class.new(app, app_key: "app_key", app_secret: "app_secret") } + + describe "#call" do + it "injects authentication headers" do + allow(middleware).to receive(:current_utc_timestamp).and_return("1672531200") + allow(middleware).to receive(:generate_nonce).and_return("NONCE1234567890") + + env = Struct.new(:request_headers).new({}) + + middleware.call(env) + + expect(env.request_headers["AppKey"]).to eq("app_key") + expect(env.request_headers["Nonce"]).to eq("NONCE1234567890") + expect(env.request_headers["CurTime"]).to eq("1672531200") + expected_checksum = Digest::SHA256.hexdigest("app_secretNONCE12345678901672531200") + expect(env.request_headers["CheckSum"]).to eq(expected_checksum) + end + end + + describe "nonce generation" do + it "creates a 16-character alphanumeric string" do + nonce = middleware.send(:generate_nonce) + expect(nonce.length).to eq(16) + expect(nonce).to match(/\A[0-9a-zA-Z]+\z/) + end + end + + describe "checksum calculation" do + it "computes SHA256 of secret + nonce + cur_time" do + checksum = middleware.send(:checksum_for, "noncevalue", "1672531200") + expect(checksum).to eq(Digest::SHA256.hexdigest("app_secretnoncevalue1672531200")) + end + end +end diff --git a/spec/novacloud_client/middleware/error_handler_spec.rb b/spec/novacloud_client/middleware/error_handler_spec.rb new file mode 100644 index 0000000..8821624 --- /dev/null +++ b/spec/novacloud_client/middleware/error_handler_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" +require "faraday" + +RSpec.describe NovacloudClient::Middleware::ErrorHandler do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:connection) do + Faraday.new do |faraday| + faraday.use described_class + faraday.adapter :test, stubs + end + end + + it "returns successful responses untouched" do + stubs.get("/ok") { [200, {}, "success"] } + + response = connection.get("/ok") + + expect(response.body).to eq("success") + end + + it "raises mapped client errors" do + stubs.get("/unauthorized") { [401, {}, { "msg" => "nope" }] } + + expect { connection.get("/unauthorized") }.to raise_error(NovacloudClient::AuthenticationError, /401/) + end + + it "raises mapped server errors" do + stubs.get("/down") { [503, {}, "maintenance"] } + + expect { connection.get("/down") }.to raise_error(NovacloudClient::ServiceUnavailableError) + end + + it "falls back to generic errors when unmapped" do + stubs.get("/weird") { [418, {}, "teapot"] } + + expect { connection.get("/weird") }.to raise_error(NovacloudClient::ClientError) + end +end diff --git a/spec/novacloud_client_spec.rb b/spec/novacloud_client_spec.rb index e5e630b..a8e716c 100644 --- a/spec/novacloud_client_spec.rb +++ b/spec/novacloud_client_spec.rb @@ -4,8 +4,7 @@ it "has a version number" do expect(NovacloudClient::VERSION).not_to be nil end - - it "does something useful" do - expect(false).to eq(true) + it "defines a base error class" do + expect(NovacloudClient::Error).to be < StandardError end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5616916..abac13e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "novacloud_client" +require "webmock/rspec" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure @@ -12,4 +13,6 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + WebMock.disable_net_connect!(allow_localhost: true) end From ffa0aab12f8fb11c9ed17bf367bfad8f68942eb7 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:24:05 +1100 Subject: [PATCH 3/4] pass 2 cop and spec --- .rubocop.yml | 8 +++- Gemfile | 11 ++++-- Gemfile.lock | 5 +-- lib/novacloud_client.rb | 12 +++--- lib/novacloud_client/client.rb | 37 ++++++++++--------- .../middleware/authentication.rb | 14 +++---- .../middleware/error_handler.rb | 12 ++---- novacloud_client.gemspec | 14 +++---- 8 files changed, 59 insertions(+), 54 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index ae378d0..02e1a87 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,11 @@ AllCops: - TargetRubyVersion: 3.2 + TargetRubyVersion: 2.7 + NewCops: enable + +Metrics/BlockLength: + Exclude: + - "spec/**/*" + - "novacloud_client.gemspec" Style/StringLiterals: EnforcedStyle: double_quotes diff --git a/Gemfile b/Gemfile index d960374..691cb01 100644 --- a/Gemfile +++ b/Gemfile @@ -6,8 +6,11 @@ source "https://rubygems.org" gemspec gem "irb" -gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" - -gem "rubocop", "~> 1.21" +group :development, :test do + gem "rake", "~> 13.0" + gem "rspec", "~> 3.12" + gem "rubocop", "~> 1.60", require: false + gem "webmock", "~> 3.19" + gem "yard", "~> 0.9" +end diff --git a/Gemfile.lock b/Gemfile.lock index e924e9f..9b211d1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,12 +103,11 @@ PLATFORMS ruby DEPENDENCIES - bundler (~> 2.4) irb novacloud_client! rake (~> 13.0) - rspec (~> 3.12, ~> 3.0) - rubocop (~> 1.21) + rspec (~> 3.12) + rubocop (~> 1.60) webmock (~> 3.19) yard (~> 0.9) diff --git a/lib/novacloud_client.rb b/lib/novacloud_client.rb index e8a55d7..5958c82 100644 --- a/lib/novacloud_client.rb +++ b/lib/novacloud_client.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require 'faraday' -require 'json' +require "faraday" +require "json" -require_relative 'novacloud_client/version' -require_relative 'novacloud_client/errors' -require_relative 'novacloud_client/configuration' -require_relative 'novacloud_client/client' +require_relative "novacloud_client/version" +require_relative "novacloud_client/errors" +require_relative "novacloud_client/configuration" +require_relative "novacloud_client/client" diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb index 5f9604d..2fa6933 100644 --- a/lib/novacloud_client/client.rb +++ b/lib/novacloud_client/client.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'faraday' -require 'json' +require "faraday" +require "json" -require_relative 'errors' -require_relative 'configuration' -require_relative 'middleware/authentication' -require_relative 'middleware/error_handler' +require_relative "errors" +require_relative "configuration" +require_relative "middleware/authentication" +require_relative "middleware/error_handler" module NovacloudClient # Central entry point for interacting with the NovaCloud API. @@ -31,16 +31,7 @@ def request(http_method:, endpoint:, params: {}) symbolized_method = http_method.to_sym response = connection.public_send(symbolized_method) do |req| req.url endpoint - - case symbolized_method - when :get, :delete - req.params.update(params) unless params.empty? - else - unless params.empty? - req.headers['Content-Type'] = 'application/json; charset=utf-8' - req.body = JSON.generate(params) - end - end + apply_request_payload(req, symbolized_method, params) end parse_body(response) @@ -50,7 +41,7 @@ def request(http_method:, endpoint:, params: {}) def build_connection Faraday.new(url: config.base_url) do |faraday| - faraday.headers['Accept'] = 'application/json' + faraday.headers["Accept"] = "application/json" faraday.use Middleware::Authentication, app_key: config.app_key, @@ -63,6 +54,18 @@ def build_connection end end + def apply_request_payload(request, http_method, params) + return if params.empty? + + case http_method + when :get, :delete + request.params.update(params) + else + request.headers["Content-Type"] = "application/json; charset=utf-8" + request.body = JSON.generate(params) + end + end + def parse_body(response) body = response.body return nil if body.nil? diff --git a/lib/novacloud_client/middleware/authentication.rb b/lib/novacloud_client/middleware/authentication.rb index f28a65f..366569f 100644 --- a/lib/novacloud_client/middleware/authentication.rb +++ b/lib/novacloud_client/middleware/authentication.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'faraday' -require 'digest' -require 'securerandom' +require "faraday" +require "digest" +require "securerandom" module NovacloudClient module Middleware @@ -20,10 +20,10 @@ def call(env) checksum = checksum_for(nonce, cur_time) headers = env.request_headers - headers['AppKey'] = @app_key - headers['Nonce'] = nonce - headers['CurTime'] = cur_time - headers['CheckSum'] = checksum + headers["AppKey"] = @app_key + headers["Nonce"] = nonce + headers["CurTime"] = cur_time + headers["CheckSum"] = checksum @app.call(env) end diff --git a/lib/novacloud_client/middleware/error_handler.rb b/lib/novacloud_client/middleware/error_handler.rb index 45b5eb6..2967aa7 100644 --- a/lib/novacloud_client/middleware/error_handler.rb +++ b/lib/novacloud_client/middleware/error_handler.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'faraday' +require "faraday" -require_relative '../errors' +require_relative "../errors" module NovacloudClient module Middleware @@ -20,10 +20,6 @@ class ErrorHandler < Faraday::Middleware 504 => NovacloudClient::GatewayTimeoutError }.freeze - def initialize(app) - super(app) - end - def call(env) @app.call(env).on_complete do |response_env| handle_response(response_env) @@ -51,10 +47,10 @@ def fallback_error(status) def summary_from(env) body = env.body - return 'No response body' if body.nil? + return "No response body" if body.nil? if body.is_a?(String) - body.strip.empty? ? 'Empty body' : body.strip[0, 200] + body.strip.empty? ? "Empty body" : body.strip[0, 200] elsif body.respond_to?(:to_json) body.to_json[0, 200] else diff --git a/novacloud_client.gemspec b/novacloud_client.gemspec index 5a91bf7..f6a7282 100644 --- a/novacloud_client.gemspec +++ b/novacloud_client.gemspec @@ -9,13 +9,17 @@ Gem::Specification.new do |spec| spec.email = ["chayut@canopusnet.com"] spec.summary = "Ruby client for the NovaCloud API." - spec.description = "A Faraday-based Ruby client for the NovaCloud Open Platform that handles authentication, error mapping, and resource abstractions." + spec.description = <<~DESC.strip + Faraday-based Ruby client for the NovaCloud Open Platform. + Handles authentication, error mapping, and resource abstractions. + DESC spec.homepage = "https://github.com/canopusnet/novacloud_client" spec.license = "MIT" spec.required_ruby_version = ">= 2.7.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage spec.metadata["changelog_uri"] = "https://github.com/canopusnet/novacloud_client/blob/main/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -32,13 +36,7 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" - spec.add_runtime_dependency "faraday", "~> 2.7" - - spec.add_development_dependency "bundler", "~> 2.4" - spec.add_development_dependency "rake", "~> 13.0" - spec.add_development_dependency "rspec", "~> 3.12" - spec.add_development_dependency "webmock", "~> 3.19" - spec.add_development_dependency "yard", "~> 0.9" + spec.add_dependency "faraday", "~> 2.7" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html From 0cd6549e4290856630d266f2f69870e652465a82 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:30:14 +1100 Subject: [PATCH 4/4] update read me --- README.md | 51 ++++++++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 84ededf..f2dcbcd 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,36 @@ -# NovacloudClient +# NovaCloud Client (WIP) -TODO: Delete this and the text below, and describe your gem +Sprint 01 delivered the core HTTP client for the NovaCloud Open Platform. The gem now: -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/novacloud_client`. To experiment with that code, run `bin/console` for an interactive prompt. +- Manages configuration once (`app_key`, `app_secret`, `service_domain`). +- Handles authentication headers automatically via Faraday middleware. +- Maps HTTP errors to a typed exception hierarchy. +- Normalizes GET/POST payloads and parses JSON responses. -## Installation +## Quick Start -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +```ruby +require "novacloud_client" -Install the gem and add to the application's Gemfile by executing: +client = NovacloudClient::Client.new( + app_key: "YOUR_APP_KEY", + app_secret: "YOUR_APP_SECRET", + service_domain: "open-us.vnnox.com" +) -```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +response = client.request( + http_method: :get, + endpoint: "/v2/player/list", + params: { start: 0, count: 20 } +) ``` -If bundler is not being used to manage dependencies, install the gem by executing: +### Development ```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +bundle install +bundle exec rspec +bundle exec rubocop ``` -## Usage - -TODO: Write usage instructions here - -## Development - -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. - -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). - -## Contributing - -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/novacloud_client. - -## License - -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +Sprint 02 will add resource helpers (e.g., `client.players.list`) and response objects built on these foundations.