From c3855bfa3dbdd716aa5921236617ced08a0d3138 Mon Sep 17 00:00:00 2001 From: jfromijn Date: Mon, 18 Aug 2025 16:34:51 +0200 Subject: [PATCH 1/4] initial rewrite --- .rubocop.yml | 1 - .rubocop_todo.yml | 298 ------------------ Gemfile.lock | 1 + Rakefile | 10 +- examples/appointments.rb | 66 ++-- examples/forms.rb | 24 +- examples/groups.rb | 14 +- examples/promotions.rb | 16 +- examples/schedules.rb | 20 +- examples/users.rb | 38 +-- lib/supersaas-api-client.rb | 2 +- lib/supersaas-api-client/api/appointments.rb | 137 ++++---- lib/supersaas-api-client/api/base_api.rb | 64 ++-- lib/supersaas-api-client/api/forms.rb | 18 +- lib/supersaas-api-client/api/groups.rb | 2 +- lib/supersaas-api-client/api/promotions.rb | 14 +- lib/supersaas-api-client/api/schedules.rb | 10 +- lib/supersaas-api-client/api/users.rb | 101 +++--- lib/supersaas-api-client/client.rb | 209 ++---------- lib/supersaas-api-client/configuration.rb | 29 ++ lib/supersaas-api-client/http_client.rb | 201 ++++++++++++ .../models/appointment.rb | 6 +- lib/supersaas-api-client/models/user.rb | 2 +- lib/supersaas-api-client/rate_limiter.rb | 44 +++ lib/supersaas-api-client/version.rb | 4 +- lib/supersaas.rb | 45 +-- supersaas-api-client.gemspec | 38 +-- test/appointments_test.rb | 51 ++- test/client_test.rb | 264 +++++++++++++--- test/forms_test.rb | 8 +- test/groups_test.rb | 4 +- test/promotions_test.rb | 12 +- test/schedules_test.rb | 8 +- test/test_helper.rb | 16 +- test/users_test.rb | 30 +- 35 files changed, 901 insertions(+), 906 deletions(-) delete mode 100644 .rubocop.yml delete mode 100644 .rubocop_todo.yml create mode 100644 lib/supersaas-api-client/configuration.rb create mode 100644 lib/supersaas-api-client/http_client.rb create mode 100644 lib/supersaas-api-client/rate_limiter.rb diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index cc32da4..0000000 --- a/.rubocop.yml +++ /dev/null @@ -1 +0,0 @@ -inherit_from: .rubocop_todo.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 648c5b7..0000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,298 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2024-01-17 11:07:18 UTC using RuboCop version 1.60.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 2 -# Configuration parameters: Severity, Include. -# Include: **/*.gemspec -Gemspec/RequiredRubyVersion: - Exclude: - - 'supersaas-api-client.gemspec' - - 'supersaas-api-client/supersaas-api-client.gemspec' - -# Offense count: 8 -# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. -Metrics/AbcSize: - Max: 72 - -# Offense count: 2 -# Configuration parameters: CountComments, CountAsOne. -Metrics/ClassLength: - Max: 200 - -# Offense count: 3 -# Configuration parameters: AllowedMethods, AllowedPatterns. -Metrics/CyclomaticComplexity: - Max: 20 - -# Offense count: 9 -# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. -Metrics/MethodLength: - Max: 71 - -# Offense count: 7 -# Configuration parameters: CountKeywordArgs. -Metrics/ParameterLists: - MaxOptionalParameters: 9 - Max: 10 - -# Offense count: 3 -# Configuration parameters: AllowedMethods, AllowedPatterns. -Metrics/PerceivedComplexity: - Max: 17 - -# Offense count: 1 -# Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. -# CheckDefinitionPathHierarchyRoots: lib, spec, test, src -# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS -Naming/FileName: - Exclude: - - 'lib/supersaas-api-client.rb' - -# Offense count: 22 -# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. -# SupportedStyles: snake_case, normalcase, non_integer -# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 -Naming/VariableNumber: - Exclude: - - 'lib/supersaas-api-client/api/appointments.rb' - - 'lib/supersaas-api-client/api/users.rb' - - 'lib/supersaas-api-client/models/appointment.rb' - - 'lib/supersaas-api-client/models/user.rb' - - 'test/appointments_test.rb' - - 'test/users_test.rb' - - -# Offense count: 18 -# Configuration parameters: AllowedConstants. -Style/Documentation: - Enabled: false - -# Offense count: 6 -# This cop supports safe autocorrection (--autocorrect). -Style/IfUnlessModifier: - Exclude: - - 'lib/supersaas-api-client/api/base_api.rb' - - 'lib/supersaas-api-client/client.rb' - -# Offense count: 5 -# Configuration parameters: AllowedMethods. -# AllowedMethods: respond_to_missing? -Style/OptionalBooleanParameter: - Exclude: - - 'lib/supersaas-api-client/api/appointments.rb' - -# Offense count: 6 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. -# URISchemes: http, https -Layout/LineLength: - Max: 191 - -Gemspec/DeprecatedAttributeAssignment: # new in 1.30 - Enabled: true -Gemspec/DevelopmentDependencies: # new in 1.44 - Enabled: false -Gemspec/RequireMFA: # new in 1.23 - Enabled: true -Layout/LineContinuationLeadingSpace: # new in 1.31 - Enabled: true -Layout/LineContinuationSpacing: # new in 1.31 - Enabled: true -Layout/LineEndStringConcatenationIndentation: # new in 1.18 - Enabled: true -Layout/SpaceBeforeBrackets: # new in 1.7 - Enabled: true -Lint/AmbiguousAssignment: # new in 1.7 - Enabled: true -Lint/AmbiguousOperatorPrecedence: # new in 1.21 - Enabled: true -Lint/AmbiguousRange: # new in 1.19 - Enabled: true -Lint/ConstantOverwrittenInRescue: # new in 1.31 - Enabled: true -Lint/DeprecatedConstants: # new in 1.8 - Enabled: true -Lint/DuplicateBranch: # new in 1.3 - Enabled: true -Lint/DuplicateMagicComment: # new in 1.37 - Enabled: true -Lint/DuplicateMatchPattern: # new in 1.50 - Enabled: true -Lint/DuplicateRegexpCharacterClassElement: # new in 1.1 - Enabled: true -Lint/EmptyBlock: # new in 1.1 - Enabled: true -Lint/EmptyClass: # new in 1.3 - Enabled: true -Lint/EmptyInPattern: # new in 1.16 - Enabled: true -Lint/IncompatibleIoSelectWithFiberScheduler: # new in 1.21 - Enabled: true -Lint/ItWithoutArgumentsInBlock: # new in 1.59 - Enabled: true -Lint/LambdaWithoutLiteralBlock: # new in 1.8 - Enabled: true -Lint/LiteralAssignmentInCondition: # new in 1.58 - Enabled: true -Lint/MixedCaseRange: # new in 1.53 - Enabled: true -Lint/NoReturnInBeginEndBlocks: # new in 1.2 - Enabled: true -Lint/NonAtomicFileOperation: # new in 1.31 - Enabled: true -Lint/NumberedParameterAssignment: # new in 1.9 - Enabled: true -Lint/OrAssignmentToConstant: # new in 1.9 - Enabled: true -Lint/RedundantDirGlobSort: # new in 1.8 - Enabled: true -Lint/RedundantRegexpQuantifiers: # new in 1.53 - Enabled: true -Lint/RefinementImportMethods: # new in 1.27 - Enabled: true -Lint/RequireRangeParentheses: # new in 1.32 - Enabled: true -Lint/RequireRelativeSelfPath: # new in 1.22 - Enabled: true -Lint/SymbolConversion: # new in 1.9 - Enabled: true -Lint/ToEnumArguments: # new in 1.1 - Enabled: true -Lint/TripleQuotes: # new in 1.9 - Enabled: true -Lint/UnexpectedBlockArity: # new in 1.5 - Enabled: true -Lint/UnmodifiedReduceAccumulator: # new in 1.1 - Enabled: true -Lint/UselessRescue: # new in 1.43 - Enabled: true -Lint/UselessRuby2Keywords: # new in 1.23 - Enabled: true -Metrics/CollectionLiteralLength: # new in 1.47 - Enabled: true -Naming/BlockForwarding: # new in 1.24 - Enabled: true -Security/CompoundHash: # new in 1.28 - Enabled: true -Security/IoMethods: # new in 1.22 - Enabled: true -Style/ArgumentsForwarding: # new in 1.1 - Enabled: true -Style/ArrayIntersect: # new in 1.40 - Enabled: true -Style/CollectionCompact: # new in 1.2 - Enabled: true -Style/ComparableClamp: # new in 1.44 - Enabled: true -Style/ConcatArrayLiterals: # new in 1.41 - Enabled: true -Style/DataInheritance: # new in 1.49 - Enabled: true -Style/DirEmpty: # new in 1.48 - Enabled: true -Style/DocumentDynamicEvalDefinition: # new in 1.1 - Enabled: true -Style/EmptyHeredoc: # new in 1.32 - Enabled: true -Style/EndlessMethod: # new in 1.8 - Enabled: true -Style/EnvHome: # new in 1.29 - Enabled: true -Style/ExactRegexpMatch: # new in 1.51 - Enabled: true -Style/FetchEnvVar: # new in 1.28 - Enabled: true -Style/FileEmpty: # new in 1.48 - Enabled: true -Style/FileRead: # new in 1.24 - Enabled: true -Style/FileWrite: # new in 1.24 - Enabled: true -Style/HashConversion: # new in 1.10 - Enabled: true -Style/HashExcept: # new in 1.7 - Enabled: true -Style/IfWithBooleanLiteralBranches: # new in 1.9 - Enabled: true -Style/InPatternThen: # new in 1.16 - Enabled: true -Style/MagicCommentFormat: # new in 1.35 - Enabled: true -Style/MapCompactWithConditionalBlock: # new in 1.30 - Enabled: true -Style/MapToHash: # new in 1.24 - Enabled: true -Style/MapToSet: # new in 1.42 - Enabled: true -Style/MinMaxComparison: # new in 1.42 - Enabled: true -Style/MultilineInPatternThen: # new in 1.16 - Enabled: true -Style/NegatedIfElseCondition: # new in 1.2 - Enabled: true -Style/NestedFileDirname: # new in 1.26 - Enabled: true -Style/NilLambda: # new in 1.3 - Enabled: true -Style/NumberedParameters: # new in 1.22 - Enabled: true -Style/NumberedParametersLimit: # new in 1.22 - Enabled: true -Style/ObjectThen: # new in 1.28 - Enabled: true -Style/OpenStructUse: # new in 1.23 - Enabled: true -Style/OperatorMethodCall: # new in 1.37 - Enabled: true -Style/QuotedSymbols: # new in 1.16 - Enabled: true -Style/RedundantArgument: # new in 1.4 - Enabled: true -Style/RedundantArrayConstructor: # new in 1.52 - Enabled: true -Style/RedundantConstantBase: # new in 1.40 - Enabled: true -Style/RedundantCurrentDirectoryInPath: # new in 1.53 - Enabled: true -Style/RedundantDoubleSplatHashBraces: # new in 1.41 - Enabled: true -Style/RedundantEach: # new in 1.38 - Enabled: true -Style/RedundantFilterChain: # new in 1.52 - Enabled: true -Style/RedundantHeredocDelimiterQuotes: # new in 1.45 - Enabled: true -Style/RedundantInitialize: # new in 1.27 - Enabled: true -Style/RedundantLineContinuation: # new in 1.49 - Enabled: true -Style/RedundantRegexpArgument: # new in 1.53 - Enabled: true -Style/RedundantRegexpConstructor: # new in 1.52 - Enabled: true -Style/RedundantSelfAssignmentBranch: # new in 1.19 - Enabled: true -Style/RedundantStringEscape: # new in 1.37 - Enabled: true -Style/ReturnNilInPredicateMethodDefinition: # new in 1.53 - Enabled: true -Style/SelectByRegexp: # new in 1.22 - Enabled: true -Style/SingleLineDoEndBlock: # new in 1.57 - Enabled: true -Style/StringChars: # new in 1.12 - Enabled: true -Style/SuperWithArgsParentheses: # new in 1.58 - Enabled: true -Style/SwapValues: # new in 1.1 - Enabled: true -Style/YAMLFileRead: # new in 1.53 - Enabled: true - -AllCops: - SuggestExtensions: false diff --git a/Gemfile.lock b/Gemfile.lock index c4570c8..fc72ec4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,6 +37,7 @@ GEM PLATFORMS ruby + x64-mingw32 DEPENDENCIES bundler diff --git a/Rakefile b/Rakefile index 4caa98e..8ca97e1 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'bundler/gem_tasks' -require 'rake/testtask' +require "bundler/gem_tasks" +require "rake/testtask" Rake::TestTask.new(:test) do |t| - t.libs << 'test' - t.libs << 'lib' - t.test_files = FileList['test/**/*_test.rb'] + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] end task default: :test diff --git a/examples/appointments.rb b/examples/appointments.rb index fcfd19a..c2f99d4 100755 --- a/examples/appointments.rb +++ b/examples/appointments.rb @@ -1,37 +1,37 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'date' -require 'supersaas-api-client' +require "date" +require "supersaas-api-client" -puts '# SuperSaaS Appointments Example' +puts "# SuperSaaS Appointments Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts 'SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/appointments.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts "SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/appointments.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API Key: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API Key: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -if ENV['SSS_API_SCHEDULE'] - schedule_id = ENV['SSS_API_SCHEDULE'] - show_slot = ENV['SSS_API_SLOT'] ? true : false +if ENV["SSS_API_SCHEDULE"] + schedule_id = ENV["SSS_API_SCHEDULE"] + show_slot = ENV["SSS_API_SLOT"] ? true : false else - puts 'ERROR! Missing schedule id. Rerun the script with your schedule id, e.g.' - puts ' SSS_API_SCHEDULE= ./examples/appointments.rb' + puts "ERROR! Missing schedule id. Rerun the script with your schedule id, e.g." + puts " SSS_API_SCHEDULE= ./examples/appointments.rb" return end # give -user_id = ENV.fetch('SSS_API_USER', nil) +user_id = ENV.fetch("SSS_API_USER", nil) unless user_id - puts 'User is created and then deleted at the end' - params = { full_name: 'Example', name: 'example@example.com', email: 'example@example.com', api_key: 'example' } + puts "User is created and then deleted at the end" + params = {full_name: "Example", name: "example@example.com", email: "example@example.com", api_key: "example"} user = Supersaas::Client.instance.users.create(params) user_id = user.match(%r{users/(\d+)\.json})[1] puts "#New user created #{user_id}" @@ -39,71 +39,71 @@ description = nil if user_id - description = '1234567890.' - params = { full_name: 'Example', description: description, name: 'example@example.com', email: 'example@example.com', - mobile: '555-5555', phone: '555-5555', address: 'addr' } + description = "1234567890." + params = {full_name: "Example", description: description, name: "example@example.com", email: "example@example.com", + mobile: "555-5555", phone: "555-5555", address: "addr"} if show_slot - params[:slot_id] = ENV.fetch('SSS_API_SLOT', nil) + params[:slot_id] = ENV.fetch("SSS_API_SLOT", nil) else days = rand(1..30) params[:start] = Time.now + (days * 24 * 60 * 60) params[:finish] = params[:start] + (60 * 60) end - puts 'creating new appointment...' + puts "creating new appointment..." puts "#### Supersaas::Client.instance.appointments.create(#{schedule_id}, #{user_id}, {...})" Supersaas::Client.instance.appointments.create(schedule_id, user_id, params) else - puts 'skipping create/update/delete (NO DESTRUCTIVE ACTIONS FOR SCHEDULE DATA)...' + puts "skipping create/update/delete (NO DESTRUCTIVE ACTIONS FOR SCHEDULE DATA)..." end -puts 'listing appointments...' +puts "listing appointments..." puts "#### Supersaas::Client.instance.appointments.list(#{schedule_id}, nil, nil, 25)" appointments = Supersaas::Client.instance.appointments.list(schedule_id, nil, nil, 25) if appointments.size.positive? appointment_id = appointments.sample.id - puts 'getting appointment...' + puts "getting appointment..." puts "#### Supersaas::Client.instance.appointments.get(#{appointment_id})" Supersaas::Client.instance.appointments.get(schedule_id, appointment_id) end -puts 'listing changes...' +puts "listing changes..." from = DateTime.now - 120 to = DateTime.now + 360_000 puts "#### Supersaas::Client.instance.appointments.changes(#{schedule_id}, - '#{from.strftime('%Y-%m-%d %H:%M:%S')}', '#{to.strftime('%Y-%m-%d %H:%M:%S')}', #{show_slot || 'false'})" + '#{from.strftime("%Y-%m-%d %H:%M:%S")}', '#{to.strftime("%Y-%m-%d %H:%M:%S")}', #{show_slot || "false"})" Supersaas::Client.instance.appointments.changes(schedule_id, from, show_slot) -puts 'listing available...' +puts "listing available..." from = DateTime.now puts "#### Supersaas::Client.instance.appointments.available(#{schedule_id}, - '#{from.strftime('%Y-%m-%d %H:%M:%S')}')" + '#{from.strftime("%Y-%m-%d %H:%M:%S")}')" Supersaas::Client.instance.appointments.available(schedule_id, from) -puts 'Appointments for a single user...' +puts "Appointments for a single user..." user = Supersaas::Client.instance.users.list(nil, 1).first from = DateTime.now puts "#### Supersaas::Client.instance.appointments.agenda(#{schedule_id}, user.id, - '#{from.strftime('%Y-%m-%d %H:%M:%S')}')" -Supersaas::Client.instance.appointments.agenda(schedule_id, user.id, from.strftime('%Y-%m-%d %H:%M:%S')) + '#{from.strftime("%Y-%m-%d %H:%M:%S")}')" +Supersaas::Client.instance.appointments.agenda(schedule_id, user.id, from.strftime("%Y-%m-%d %H:%M:%S")) # Update and delete appointments appointments.each do |appointment| puts "#{description} == #{appointment.description}" next unless description == appointment.description - puts 'updating appointment...' + puts "updating appointment..." puts "#### Supersaas::Client.instance.appointments.update(#{schedule_id}, #{appointment.id}, {...})" - Supersaas::Client.instance.appointments.update(schedule_id, appointment.id, { country: 'FR', address: 'Rue 1' }) + Supersaas::Client.instance.appointments.update(schedule_id, appointment.id, {country: "FR", address: "Rue 1"}) - puts 'deleting appointment...' + puts "deleting appointment..." puts "#### Supersaas::Client.instance.appointments.delete(#{schedule_id}. #{appointment.id})" Supersaas::Client.instance.appointments.delete(schedule_id, appointment.id) break end # Puts delete user -Supersaas::Client.instance.users.delete(user_id) unless ENV.fetch('SSS_API_USER', nil) +Supersaas::Client.instance.users.delete(user_id) unless ENV.fetch("SSS_API_USER", nil) diff --git a/examples/forms.rb b/examples/forms.rb index ac34ca1..c64a7b5 100755 --- a/examples/forms.rb +++ b/examples/forms.rb @@ -1,37 +1,37 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'supersaas-api-client' +require "supersaas-api-client" -puts '# SuperSaaS Forms Example' +puts "# SuperSaaS Forms Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts ' SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/appointments.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts " SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/appointments.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API Key: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API Key: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -puts 'You will need to create a form, and also attach the form to a booking, see documentation on how to do that' -puts 'The below example will take a form in random, and if it is not attached to something then 404 error will be raised' +puts "You will need to create a form, and also attach the form to a booking, see documentation on how to do that" +puts "The below example will take a form in random, and if it is not attached to something then 404 error will be raised" -puts 'listing forms...' -puts '#### Supersaas::Client.instance.forms.forms' +puts "listing forms..." +puts "#### Supersaas::Client.instance.forms.forms" template_forms = Supersaas::Client.instance.forms.forms if template_forms.size.positive? template_form_id = template_forms.sample.id - puts 'listing forms from account' - puts '#### Supersaas::Client.instance.forms.list' + puts "listing forms from account" + puts "#### Supersaas::Client.instance.forms.list" form_id = Supersaas::Client.instance.forms.list(template_form_id).sample.id end -puts 'getting form...' +puts "getting form..." puts "#### Supersaas::Client.instance.forms.get(#{form_id})" Supersaas::Client.instance.forms.get(form_id) diff --git a/examples/groups.rb b/examples/groups.rb index a10218c..d295eb1 100644 --- a/examples/groups.rb +++ b/examples/groups.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true -require 'supersaas-api-client' +require "supersaas-api-client" -puts '# SuperSaaS Groups Example' +puts "# SuperSaaS Groups Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts 'SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts "SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API KEY: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API KEY: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -puts 'listing groups...' -puts '#### Supersaas::Client.instance.groups.list' +puts "listing groups..." +puts "#### Supersaas::Client.instance.groups.list" Supersaas::Client.instance.groups.list diff --git a/examples/promotions.rb b/examples/promotions.rb index 964c8c1..cbc9de3 100644 --- a/examples/promotions.rb +++ b/examples/promotions.rb @@ -1,26 +1,26 @@ # frozen_string_literal: true -require 'supersaas-api-client' +require "supersaas-api-client" -puts '# SuperSaaS Promotions Example' +puts "# SuperSaaS Promotions Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts 'SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts "SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API KEY: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API KEY: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -puts 'listing promotions...' -puts '#### Supersaas::Client.instance.promotions.list' +puts "listing promotions..." +puts "#### Supersaas::Client.instance.promotions.list" promotions = Supersaas::Client.instance.promotions.list [10, promotions.size].min&.times do |i| - puts 'A promotion' + puts "A promotion" puts "#### Supersaas::Client.instance.promotion(#{promotions[i].id})" Supersaas::Client.instance.promotions.promotion(promotions[i].code) end diff --git a/examples/schedules.rb b/examples/schedules.rb index 5ec1483..33f902c 100755 --- a/examples/schedules.rb +++ b/examples/schedules.rb @@ -1,37 +1,37 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'supersaas-api-client' +require "supersaas-api-client" -puts '# SuperSaaS Schedules Example' +puts "# SuperSaaS Schedules Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts 'SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts "SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API KEY: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API KEY: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -puts 'listing schedules...' -puts '#### Supersaas::Client.instance.schedules.list' +puts "listing schedules..." +puts "#### Supersaas::Client.instance.schedules.list" schedules = Supersaas::Client.instance.schedules.list -puts 'listing schedule resources...' +puts "listing schedule resources..." [10, schedules.size].min&.times do |i| puts "#### Supersaas::Client.instance.schedules.resources(#{schedules[i].id})" # Capacity schedules bomb begin Supersaas::Client.instance.schedules.resources(schedules[i].id) - rescue StandardError + rescue next end end -puts 'puts listing fields...' +puts "puts listing fields..." [10, schedules.size].min&.times do |i| puts "#### Supersaas::Client.instance.schedules.field_list(#{schedules[i].id})" Supersaas::Client.instance.schedules.field_list(schedules[i].id) diff --git a/examples/users.rb b/examples/users.rb index 672392b..3558691 100755 --- a/examples/users.rb +++ b/examples/users.rb @@ -1,29 +1,29 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'supersaas-api-client' +require "supersaas-api-client" -puts '# SuperSaaS Users Example' +puts "# SuperSaaS Users Example" unless Supersaas::Client.instance.account_name && Supersaas::Client.instance.api_key - puts 'ERROR! Missing account credentials. Rerun the script with your credentials, e.g.' - puts 'SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb' + puts "ERROR! Missing account credentials. Rerun the script with your credentials, e.g." + puts "SSS_API_ACCOUNT_NAME= SSS_API_KEY= ./examples/users.rb" return end puts "## Account: #{Supersaas::Client.instance.account_name}" -puts "## API Key: #{'*' * Supersaas::Client.instance.api_key.size}" +puts "## API Key: #{"*" * Supersaas::Client.instance.api_key.size}" Supersaas::Client.instance.verbose = true -puts 'creating new user...' -puts '#### Supersaas::Client.instance.users.create({...})' -params = { full_name: 'Example', name: 'example@example.com', email: 'example@example.com', api_key: 'example' } +puts "creating new user..." +puts "#### Supersaas::Client.instance.users.create({...})" +params = {full_name: "Example", name: "example@example.com", email: "example@example.com", api_key: "example"} Supersaas::Client.instance.users.create(params) new_user_id = nil -puts 'listing users...' -puts '#### Supersaas::Client.instance.users.list(nil, 50)' +puts "listing users..." +puts "#### Supersaas::Client.instance.users.list(nil, 50)" users = Supersaas::Client.instance.users.list(nil, 50) users.each do |user| @@ -31,29 +31,29 @@ end if new_user_id - puts 'getting user...' + puts "getting user..." puts "#### Supersaas::Client.instance.users.get(#{new_user_id})" user = Supersaas::Client.instance.users.get(new_user_id) - puts 'updating user...' + puts "updating user..." puts "#### Supersaas::Client.instance.users.update(#{new_user_id})" - Supersaas::Client.instance.users.update(new_user_id, { country: 'FR', address: 'Rue 1' }) + Supersaas::Client.instance.users.update(new_user_id, {country: "FR", address: "Rue 1"}) - puts 'deleting user...' + puts "deleting user..." puts "#### Supersaas::Client.instance.users.delete(#{user.id})" Supersaas::Client.instance.users.delete(user.id) else - puts '... did not find user in list' + puts "... did not find user in list" end -puts 'creating user with errors...' -puts '#### Supersaas::Client.instance.users.create' +puts "creating user with errors..." +puts "#### Supersaas::Client.instance.users.create" begin - Supersaas::Client.instance.users.create({ name: 'error' }) + Supersaas::Client.instance.users.create({name: "error"}) rescue Supersaas::Exception => e puts "This raises an error #{e.message}" end -puts '#### Supersaas::Client.instance.users.field_list' +puts "#### Supersaas::Client.instance.users.field_list" Supersaas::Client.instance.users.field_list puts diff --git a/lib/supersaas-api-client.rb b/lib/supersaas-api-client.rb index 64b1b5d..f7e1cb9 100644 --- a/lib/supersaas-api-client.rb +++ b/lib/supersaas-api-client.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require_relative 'supersaas' +require_relative "supersaas" diff --git a/lib/supersaas-api-client/api/appointments.rb b/lib/supersaas-api-client/api/appointments.rb index 034461d..a3f1f55 100644 --- a/lib/supersaas-api-client/api/appointments.rb +++ b/lib/supersaas-api-client/api/appointments.rb @@ -3,127 +3,85 @@ module Supersaas # REF: https://www.supersaas.com/info/dev/appointment_api class Appointments < BaseApi - def agenda(schedule_id, user, from_time = nil, slot = false) + def agenda(schedule_id, user, from_time = nil, slot: false) path = "/agenda/#{validate_id(schedule_id)}" params = { user: validate_present(user), from: from_time && validate_datetime(from_time) } - params.merge!(slot: true) if slot + params[:slot] = true if slot res = client.get(path, params) map_slots_or_bookings(res) end # LEGACY METHOD WILL BE REMOVED USE AGENDA def agenda_slots(schedule_id, user_id, from_time = nil) - path = "/agenda/#{validate_id(schedule_id)}" - params = { - user: validate_present(user_id), - from: from_time && validate_datetime(from_time), - slot: true - } - res = client.get(path, params) - map_slots_or_bookings(res, true) + warn "[DEPRECATION] `agenda_slots` is deprecated. Use `agenda` with `slot: true` instead." + agenda(schedule_id, user_id, from_time, slot: true) end def available(schedule_id, from_time, length_minutes = nil, resource = nil, full = nil, limit = nil) path = "/free/#{validate_id(schedule_id)}" params = { - length: length_minutes && validate_number(length_minutes), + length: length_minutes ? validate_number(length_minutes) : nil, from: validate_datetime(from_time), resource: resource, full: full ? true : nil } - params.merge!(maxresults: validate_number(limit)) if limit - res = client.get(path, params) + params[:maxresults] = validate_number(limit) if limit + res = client.get(path, params.compact) map_slots_or_bookings(res) end def list(schedule_id, form = nil, start_time = nil, limit = nil, finish = nil, offset = nil) - path = '/bookings' + path = "/bookings" params = { schedule_id: validate_id(schedule_id), form: form ? true : nil, start: start_time ? validate_datetime(start_time) : nil, - finish: finish ? validate_datetime(finish) : nil - } - params.merge!(limit: validate_number(limit)) if limit - params.merge!(offset: validate_number(offset)) if offset + finish: finish ? validate_datetime(finish) : nil, + limit: limit ? validate_number(limit) : nil, + offset: offset ? validate_number(offset) : nil + }.compact + res = client.get(path, params) map_slots_or_bookings(res) end def get(schedule_id, appointment_id) - params = { schedule_id: validate_id(schedule_id) } + params = {schedule_id: validate_id(schedule_id)} path = "/bookings/#{validate_id(appointment_id)}" res = client.get(path, params) Supersaas::Appointment.new(res) end def create(schedule_id, user_id, attributes, form = nil, webhook = nil) - path = '/bookings' + path = "/bookings" params = { - schedule_id: schedule_id, + schedule_id: validate_id(schedule_id), # Add validation here webhook: webhook, user_id: validate_id(user_id), form: form ? true : nil, - booking: { - start: attributes[:start], - finish: attributes[:finish], - email: attributes[:email], - res_name: attributes[:name], - full_name: attributes[:full_name], - address: attributes[:address], - mobile: attributes[:mobile], - phone: attributes[:phone], - country: attributes[:country], - field_1: attributes[:field_1], - field_2: attributes[:field_2], - field_1_r: attributes[:field_1_r], - field_2_r: attributes[:field_2_r], - super_field: attributes[:super_field], - resource_id: attributes[:resource_id], - slot_id: attributes[:slot_id] - } + booking: build_booking_attributes(attributes) } - params[:booking].compact! client.post(path, params) end def update(schedule_id, appointment_id, attributes, form = nil, webhook = nil) path = "/bookings/#{validate_id(appointment_id)}" params = { - schedule_id: schedule_id, - booking: { - start: attributes[:start], - finish: attributes[:finish], - email: attributes[:email], - res_name: attributes[:name], - full_name: attributes[:full_name], - address: attributes[:address], - mobile: attributes[:mobile], - phone: attributes[:phone], - country: attributes[:country], - field_1: attributes[:field_1], - field_2: attributes[:field_2], - field_1_r: attributes[:field_1_r], - field_2_r: attributes[:field_2_r], - super_field: attributes[:super_field], - resource_id: attributes[:resource_id], - slot_id: attributes[:slot_id] - } + schedule_id: validate_id(schedule_id), # Add validation here + booking: build_booking_attributes(attributes) } - - params.merge!(form: form) if form - params.merge!(webhook: webhook) if webhook - params[:booking].compact! + params[:form] = form if form + params[:webhook] = webhook if webhook client.put(path, params) end def delete(schedule_id, appointment_id, webhook = nil) path = "/bookings/#{validate_id(appointment_id)}" - params = { schedule_id: validate_id(schedule_id) } - params.merge!(webhook: webhook) if webhook + params = {schedule_id: validate_id(schedule_id)} + params[:webhook] = webhook if webhook client.delete(path, nil, params) end @@ -135,11 +93,11 @@ def changes(schedule_id, from_time = nil, to = nil, slot = false, user = nil, li end def range(schedule_id, today = false, from_time = nil, to = nil, slot = false, user = nil, resource_id = nil, - service_id = nil, limit = nil, offset = nil) + service_id = nil, limit = nil, offset = nil) path = "/range/#{validate_id(schedule_id)}" params = {} params = build_param(params, from_time, to, slot, user, limit, offset, resource_id, service_id) - params.merge!(today: true) if today + params[:today] = true if today res = client.get(path, params) map_slots_or_bookings(res) end @@ -151,25 +109,46 @@ def map_slots_or_bookings(obj, slot = false) obj.map { |attributes| Supersaas::Slot.new(attributes) } elsif obj.is_a?(Array) obj.map { |attributes| Supersaas::Appointment.new(attributes) } - elsif obj['slots'] - obj['slots'].map { |attributes| Supersaas::Slot.new(attributes) } - elsif obj['bookings'] - obj['bookings'].map { |attributes| Supersaas::Appointment.new(attributes) } + elsif obj["slots"] + obj["slots"].map { |attributes| Supersaas::Slot.new(attributes) } + elsif obj["bookings"] + obj["bookings"].map { |attributes| Supersaas::Appointment.new(attributes) } else [] end end def build_param(params, from_time, to, slot, user, limit, offset, resource_id = nil, service_id = nil) - params.merge!(from: validate_datetime(from_time)) if from_time - params.merge!(to: validate_datetime(to)) if to - params.merge!(slot: true) if slot - params.merge!(user: validate_user(user)) if user - params.merge!(limit: validate_number(limit)) if limit - params.merge!(offset: validate_number(offset)) if offset - params.merge!(resource_id: validate_id(resource_id)) if resource_id - params.merge!(service_id: validate_id(service_id)) if service_id + params[:from] = validate_datetime(from_time) if from_time + params[:to] = validate_datetime(to) if to + params[:slot] = true if slot + params[:user] = validate_user(user) if user + params[:limit] = validate_number(limit) if limit + params[:offset] = validate_number(offset) if offset + params[:resource_id] = validate_id(resource_id) if resource_id + params[:service_id] = validate_id(service_id) if service_id params end + + def build_booking_attributes(attributes) + { + start: attributes[:start], + finish: attributes[:finish], + email: attributes[:email], + res_name: attributes[:name], + full_name: attributes[:full_name], + address: attributes[:address], + mobile: attributes[:mobile], + phone: attributes[:phone], + country: attributes[:country], + field_1: attributes[:field_1], + field_2: attributes[:field_2], + field_1_r: attributes[:field_1_r], + field_2_r: attributes[:field_2_r], + super_field: attributes[:super_field], + resource_id: attributes[:resource_id], + slot_id: attributes[:slot_id] + }.compact + end end end diff --git a/lib/supersaas-api-client/api/base_api.rb b/lib/supersaas-api-client/api/base_api.rb index f43f032..1676cc4 100644 --- a/lib/supersaas-api-client/api/base_api.rb +++ b/lib/supersaas-api-client/api/base_api.rb @@ -4,9 +4,9 @@ module Supersaas class BaseApi attr_accessor :client - INTEGER_REGEX = /\A[0-9]+\Z/.freeze - DATETIME_REGEX = /\A\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\Z/.freeze - PROMOTION_REGEX = /\A[0-9a-zA-Z]+\Z/.freeze + INTEGER_REGEX = /\A[0-9]+\Z/ + DATETIME_REGEX = /\A\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}:\d{1,2}\Z/ + PROMOTION_REGEX = /\A[0-9a-zA-Z]+\Z/ def initialize(client) @client = client @@ -15,10 +15,15 @@ def initialize(client) protected def validate_id(value) - if value.is_a?(Integer) + case value + when Integer + raise Supersaas::Exception, "Invalid id parameter: #{value}. Must be positive." if value < 0 value - elsif value.is_a?(String) && value =~ INTEGER_REGEX - value.to_i + when String + raise Supersaas::Exception, "Invalid id parameter: #{value}. Provide a integer value." unless INTEGER_REGEX.match?(value) + parsed = value.to_i + raise Supersaas::Exception, "Invalid id parameter: #{value}. Must be positive." if parsed < 0 + parsed else raise Supersaas::Exception, "Invalid id parameter: #{value}. Provide a integer value." end @@ -39,60 +44,65 @@ def validate_number(value) end def validate_name(value) - unless value.nil? || (value.is_a?(String) && value.size) - raise Supersaas::Exception, 'Required parameter name is missing.' + return if value.nil? + + unless value.is_a?(String) && !value.strip.empty? + raise Supersaas::Exception, "Required parameter name is missing or empty." end - value + value.strip end def validate_present(value) - raise Supersaas::Exception, 'Required parameter is missing.' unless value + raise Supersaas::Exception, "Required parameter is missing." unless value value end def validate_notfound(value) - unless value.is_a?(String) && %w[error ignore].include?(value) - raise Supersaas::Exception, "Required parameter notfound can only be 'error' or 'ignore'." + valid_options = %w[ignore raise] + unless value.is_a?(String) && valid_options.include?(value) + raise Supersaas::Exception, "Notfound parameter must be one of: #{valid_options.join(", ")}, got: '#{value}'" end - value end def validate_promotion(value) unless value.is_a?(String) && value.size && value =~ PROMOTION_REGEX - raise Supersaas::Exception, - 'Required parameter promotional code not found or contains other than alphanumeric characters.' + raise Supersaas::Exception, "Required parameter promotional code not found or contains other than alphanumeric characters." end value end def validate_duplicate(value) - unless value.is_a?(String) && %w[ignore raise].include?(value) - raise Supersaas::Exception, "Required parameter duplicate can only be 'ignore'." + valid_options = %w[ignore raise] + unless value.is_a?(String) && valid_options.include?(value) + raise Supersaas::Exception, "Duplicate parameter must be one of: #{valid_options.join(", ")}, got: '#{value}'" end value end def validate_datetime(value) - if value.is_a?(String) && value =~ DATETIME_REGEX + case value + when String + unless DATETIME_REGEX.match?(value) + raise Supersaas::Exception, + "Invalid datetime parameter: #{value}. Provide a formatted 'YYYY-MM-DD HH:MM:SS' string." + end value - elsif value.is_a?(Time) || value.is_a?(DateTime) - value.strftime('%Y-%m-%d %H:%M:%S') + when Time, DateTime + value.strftime("%Y-%m-%d %H:%M:%S") else - raise ArgumentError + raise Supersaas::Exception, + "Invalid datetime parameter: #{value}. Provide a Time object or formatted 'YYYY-MM-DD HH:MM:SS' string." end - rescue ArgumentError - raise Supersaas::Exception, - "Invalid datetime parameter: #{value}. Provide a Time object or formatted 'YYYY-DD-MM HH:MM:SS' string." end - def validate_options(value, options) - unless options.include?(value) - raise Supersaas::Exception, "Invalid option parameter: #{value}. Must be one of #{options.join(', ')}." + def validate_options(value, valid_options) + unless valid_options.include?(value) + raise Supersaas::Exception, "Value must be one of: #{valid_options.join(", ")}, got: '#{value}'" end value diff --git a/lib/supersaas-api-client/api/forms.rb b/lib/supersaas-api-client/api/forms.rb index 699ef1d..2db8a0b 100644 --- a/lib/supersaas-api-client/api/forms.rb +++ b/lib/supersaas-api-client/api/forms.rb @@ -4,25 +4,25 @@ module Supersaas # REF: https://www.supersaas.com/info/dev/form_api class Forms < BaseApi def list(template_form_id, from_time = nil, user = nil, limit = nil, offset = nil) - path = '/forms' - params = { form_id: validate_id(template_form_id) } - params.merge!(from: validate_datetime(from_time)) if from_time - params.merge!(user: validate_user(user)) if user - params.merge!(limit: limit) if limit - params.merge!(offset: offset) if offset + path = "/forms" + params = {form_id: validate_id(template_form_id)} + params[:from] = validate_datetime(from_time) if from_time + params[:user] = validate_user(user) if user + params[:limit] = limit if limit + params[:offset] = offset if offset res = client.get(path, params) res.map { |attributes| Supersaas::Form.new(attributes) } end def get(form_id) - path = '/forms' - params = { id: validate_id(form_id) } + path = "/forms" + params = {id: validate_id(form_id)} res = client.get(path, params) Supersaas::Form.new(res) end def forms - path = '/super_forms' + path = "/super_forms" res = client.get(path) res.map { |attributes| Supersaas::SuperForm.new(attributes) } end diff --git a/lib/supersaas-api-client/api/groups.rb b/lib/supersaas-api-client/api/groups.rb index 5189446..134f661 100644 --- a/lib/supersaas-api-client/api/groups.rb +++ b/lib/supersaas-api-client/api/groups.rb @@ -4,7 +4,7 @@ module Supersaas class Groups < BaseApi # REF: https://www.supersaas.com/info/dev/information_api#groups def list - path = '/groups' + path = "/groups" res = client.get(path) res.map { |attributes| Supersaas::Group.new(attributes) } end diff --git a/lib/supersaas-api-client/api/promotions.rb b/lib/supersaas-api-client/api/promotions.rb index cd24b44..71ee6e6 100644 --- a/lib/supersaas-api-client/api/promotions.rb +++ b/lib/supersaas-api-client/api/promotions.rb @@ -4,24 +4,24 @@ module Supersaas class Promotions < BaseApi # REF: https://www.supersaas.com/info/dev/promotion_api def list(limit = nil, offset = nil) - path = '/promotions' + path = "/promotions" params = {} - params.merge!(limit: validate_number(limit)) if limit - params.merge!(offset: validate_number(offset)) if offset + params[:limit] = validate_number(limit) if limit + params[:offset] = validate_number(offset) if offset res = client.get(path, params) res.map { |attributes| Supersaas::Promotion.new(attributes) } end def promotion(promotion_code) - path = '/promotions' - query = { promotion_code: validate_promotion(promotion_code) } + path = "/promotions" + query = {promotion_code: validate_promotion(promotion_code)} res = client.get(path, query) res.map { |attributes| Supersaas::Promotion.new(attributes) } end def duplicate_promotion_code(promotion_code, template_code) - path = '/promotions' - query = { id: validate_promotion(promotion_code), template_code: validate_promotion(template_code) } + path = "/promotions" + query = {id: validate_promotion(promotion_code), template_code: validate_promotion(template_code)} client.post(path, query) end end diff --git a/lib/supersaas-api-client/api/schedules.rb b/lib/supersaas-api-client/api/schedules.rb index b627e40..c3c8d84 100644 --- a/lib/supersaas-api-client/api/schedules.rb +++ b/lib/supersaas-api-client/api/schedules.rb @@ -4,21 +4,21 @@ module Supersaas class Schedules < BaseApi # REF: https://www.supersaas.com/info/dev/information_api def list - path = '/schedules' + path = "/schedules" res = client.get(path) res.map { |attributes| Supersaas::Schedule.new(attributes) } end def resources(schedule_id) - path = '/resources' - query = { schedule_id: validate_id(schedule_id) } + path = "/resources" + query = {schedule_id: validate_id(schedule_id)} res = client.get(path, query) res.map { |attributes| Supersaas::Resource.new(attributes) } end def field_list(schedule_id) - path = '/field_list' - query = { schedule_id: validate_id(schedule_id) } + path = "/field_list" + query = {schedule_id: validate_id(schedule_id)} res = client.get(path, query) res.map { |attributes| Supersaas::FieldList.new(attributes) } end diff --git a/lib/supersaas-api-client/api/users.rb b/lib/supersaas-api-client/api/users.rb index 3087076..84627d5 100644 --- a/lib/supersaas-api-client/api/users.rb +++ b/lib/supersaas-api-client/api/users.rb @@ -7,97 +7,84 @@ def list(form = nil, limit = nil, offset = nil) path = user_path(nil) params = { form: form ? true : nil, - limit: limit && validate_number(limit), - offset: offset && validate_number(offset) - } - params.compact! + limit: limit ? validate_number(limit) : nil, + offset: offset ? validate_number(offset) : nil + }.compact + res = client.get(path, params) + return [] if res.nil? + res.map { |attributes| Supersaas::User.new(attributes) } end def get(user_id, form = nil) path = user_path(user_id) - params = { - form: form ? true : nil - } + params = {form: form ? true : nil}.compact res = client.get(path, params) + Supersaas::User.new(res) end def create(attributes, user_id = nil, webhook = nil, duplicate = nil) path = user_path(user_id) - query = { webhook: webhook } - query.merge!(duplicate: validate_duplicate(duplicate)) if duplicate - params = { - user: { - name: validate_name(attributes[:name]), - email: attributes[:email], - password: attributes[:password], - full_name: attributes[:full_name], - address: attributes[:address], - mobile: attributes[:mobile], - phone: attributes[:phone], - country: attributes[:country], - timezone: attributes[:timezone], - field_1: attributes[:field_1], - field_2: attributes[:field_2], - super_field: attributes[:super_field], - credit: attributes[:credit] && validate_number(attributes[:credit]), - role: attributes[:role] && validate_options(attributes[:role], User::ROLES), - group: attributes[:group] && validate_number(attributes[:group]) - } - } - params[:user].compact! - client.post(path, params, query) + query_params = {webhook: webhook} + query_params[:duplicate] = validate_duplicate(duplicate) if duplicate + + params = {user: build_user_attributes(attributes)} + client.post(path, params, query_params.compact) end def update(user_id, attributes, webhook = nil, notfound = nil) path = user_path(user_id) - query = { webhook: webhook } - query.merge!(notfound: validate_notfound(notfound)) if notfound - params = { - user: { - name: validate_name(attributes[:name]), - email: attributes[:email], - password: attributes[:password], - full_name: attributes[:full_name], - address: attributes[:address], - mobile: attributes[:mobile], - phone: attributes[:phone], - country: attributes[:country], - timezone: attributes[:timezone], - field_1: attributes[:field_1], - field_2: attributes[:field_2], - super_field: attributes[:super_field], - credit: attributes[:credit] && validate_number(attributes[:credit]), - role: attributes[:role] && validate_options(attributes[:role], User::ROLES), - group: attributes[:group] && validate_number(attributes[:group]) - } - } - params[:user].compact! - client.put(path, params, query) + query_params = {webhook: webhook} + query_params[:notfound] = validate_notfound(notfound) if notfound + + params = {user: build_user_attributes(attributes)} + client.put(path, params, query_params.compact) end def delete(user_id, webhook = nil) path = user_path(user_id) - params = { webhook: webhook } + params = {webhook: webhook} client.delete(path, nil, params) end def field_list - path = '/field_list' + path = "/field_list" res = client.get(path) + return [] if res.nil? + res.map { |attributes| Supersaas::FieldList.new(attributes) } end private def user_path(user_id) - if user_id.nil? || user_id == '' - '/users' + if user_id.nil? || user_id == "" + "/users" else "/users/#{validate_user(user_id)}" end end + + def build_user_attributes(attributes) + { + name: validate_name(attributes[:name]), + email: attributes[:email], + password: attributes[:password], + full_name: attributes[:full_name], + address: attributes[:address], + mobile: attributes[:mobile], + phone: attributes[:phone], + country: attributes[:country], + timezone: attributes[:timezone], + field_1: attributes[:field_1], + field_2: attributes[:field_2], + super_field: attributes[:super_field], + credit: attributes[:credit] ? validate_number(attributes[:credit]) : nil, + role: attributes[:role] ? validate_options(attributes[:role], User::ROLES) : nil, + group: attributes[:group] ? validate_number(attributes[:group]) : nil + }.compact + end end end diff --git a/lib/supersaas-api-client/client.rb b/lib/supersaas-api-client/client.rb index d457a8b..92ec407 100644 --- a/lib/supersaas-api-client/client.rb +++ b/lib/supersaas-api-client/client.rb @@ -1,38 +1,38 @@ # frozen_string_literal: true -require 'net/http' -require 'uri' -require 'json' +require "net/http" +require "uri" +require "json" +require "logger" +require "timeout" module Supersaas class Client class << self attr_accessor :configuration - def configure - self.configuration ||= Configuration.new - yield(configuration) + def reset_instance! + Thread.current["SUPER_SAAS_CLIENT"] = nil end - def instance - Thread.current['SUPER_SAAS_CLIENT'] ||= new(configuration || Configuration.new) + def instance(configuration = nil) + Thread.current["SUPER_SAAS_CLIENT"] ||= new(configuration || Configuration.new) end def user_agent "SSS/#{VERSION} Ruby/#{RUBY_VERSION} API/#{API_VERSION}" end end + # The Client class provides a Ruby interface to the SuperSaaS API. - attr_accessor :account_name, :api_key, :host, :dry_run, :verbose - attr_reader :last_request + attr_reader :configuration, :last_request, :rate_limiter, :http_client - def initialize(configuration = nil) - configuration ||= Configuration.new - @account_name = configuration.account_name - @api_key = configuration.api_key - @host = configuration.host - @dry_run = configuration.dry_run - @verbose = configuration.verbose + def initialize(configuration = nil, **options) + @configuration = configuration || Configuration.new + @configuration.validate! + + @rate_limiter = RateLimiter.new + @http_client = HttpClient.new(@configuration, **options) end def appointments @@ -59,177 +59,26 @@ def groups @groups ||= Groups.new(self) end - def get(path, query = {}) - request(:get, path, {}, query) - end - - def post(path, params = {}, query = {}) - request(:post, path, params, query) - end - - def put(path, params = {}, query = {}) - request(:put, path, params, query) - end - - def delete(path, params = {}, query = {}) - request(:delete, path, params, query) - end - - private - - # The rate limiter allows a maximum of 1 requests within the specified time window - WINDOW_SIZE = 1 # seconds - MAX_REQUESTS = 4 - def throttle - # A queue to store timestamps of requests made within the rate limiting window - @queue ||= Array.new(MAX_REQUESTS) - - # Represents the timestamp of the oldest request within the time window - oldest_request = @queue.push(Time.now).shift - # This ensures that the client does not make requests faster than the defined rate limit - return unless oldest_request && (d = Time.now - oldest_request) < WINDOW_SIZE - - sleep WINDOW_SIZE - d + rate_limiter.throttle end - def request(http_method, path, params = {}, query = {}) - throttle - unless account_name&.size - raise Supersaas::Exception, 'Account name not configured. Call `Supersaas::Client.configure`.' - end - unless api_key&.size - raise Supersaas::Exception, 'Account api key not configured. Call `Supersaas::Client.configure`.' - end - - uri = URI.parse(host) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = uri.scheme == 'https' - - params = delete_blank_values params - query = delete_blank_values query - - path = "/api#{path}.json" - path += "?#{URI.encode_www_form(query)}" if query.keys.size.positive? - - case http_method - when :get - req = Net::HTTP::Get.new(path) - when :post - req = Net::HTTP::Post.new(path) - req.body = params.to_json - when :put - req = Net::HTTP::Put.new(path) - req.body = params.to_json - when :delete - req = Net::HTTP::Delete.new(path) - req.body = params.to_json - else - raise Supersaas::Exception, - "Invalid HTTP Method: #{http_method}. Only `:get`, `:post`, `:put`, `:delete` supported." - end - - req.basic_auth account_name, api_key - - req['Accept'] = 'application/json' - req['Content-Type'] = 'application/json' - req['User-Agent'] = self.class.user_agent - - if verbose - puts '### SuperSaaS Client Request:' - puts "#{http_method} #{path}" - puts params.to_json - puts '------------------------------' - end - - @last_request = req - return {} if dry_run - - begin - res = http.request(req) - rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, - Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e - raise Supersaas::Exception, "HTTP Request Error (#{uri}#{path}): #{e.message}" - end - - if verbose - puts 'Response:' - puts res.inspect - puts res.body - puts '==============================' - end - - code = res.code.to_i - case code - when 200, 201 - if http_method == :post && res['location'] =~ /www.supersaas.com/ - res['location'] - else - json_body(res) - end - else - handle_errors(code, res) + %i[get post put delete].each do |method| + define_method(method) do |path, params = {}, query = {}| + params, query = {}, params if method == :get && query.empty? && !params.empty? + request(method, path, params, query) end end - def handle_errors(code, res) - print_errors(res) - case code - when 422 - raise Supersaas::Exception, 'HTTP Request Error: Unprocessable Content' - when 400 - raise Supersaas::Exception, 'HTTP Request Error: Bad Request' - when 401 - raise Supersaas::Exception, 'HTTP Request Error: Unauthorised' - when 404 - raise Supersaas::Exception, 'HTTP Request Error: Not Found' - when 501 - raise Supersaas::Exception, 'Not yet implemented for service type schedule' - when 403 - raise Supersaas::Exception, 'Unauthorized' - when 405 - raise Supersaas::Exception, 'Not available for capacity type schedule' - else - raise Supersaas::Exception, "HTTP Request Error: #{code}" - end - end - - def print_errors(res) - json_body = json_body(res) - return unless json_body[:errors] - - errors each do |error| - puts "Error code: #{error['code']}, #{error['title']}" - end - end - - def json_body(res) - res.body&.size ? JSON.parse(res.body) : {} - rescue JSON::ParserError - {} - end - - def delete_blank_values(hash) - return hash unless hash - - hash.delete_if do |_k, v| - v = v.compact if v.is_a?(Hash) - v.nil? || v == '' - end - end - - class Configuration - DEFAULT_HOST = 'https://www.supersaas.com' + private - attr_accessor :account_name, :host, :api_key, :dry_run, :verbose + # Sends an HTTP request using the specified method, path, params, and query. - def initialize - @account_name = ENV.fetch('SSS_API_ACCOUNT_NAME', nil) - @api_key = ENV.fetch('SSS_API_KEY', nil) - @host = DEFAULT_HOST - @dry_run = false - @verbose = false - end + def request(method, path, params = {}, query = {}) + rate_limiter.throttle + req = http_client.request(method, path, params, query) + @last_request = http_client.last_request + req end end end diff --git a/lib/supersaas-api-client/configuration.rb b/lib/supersaas-api-client/configuration.rb new file mode 100644 index 0000000..ee8fc7f --- /dev/null +++ b/lib/supersaas-api-client/configuration.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Supersaas + class Configuration + DEFAULT_HOST = "https://www.supersaas.com" + + attr_accessor :account_name, :host, :api_key, :dry_run, :verbose + + def initialize + @account_name = ENV.fetch("SSS_API_ACCOUNT_NAME", nil) + @api_key = ENV.fetch("SSS_API_KEY", nil) + @host = DEFAULT_HOST + @dry_run = false + @verbose = false + end + + def valid? + !account_name.to_s.empty? && !api_key.to_s.empty? && !host.to_s.empty? + end + + def validate! + raise Supersaas::Exception, "Account name is required" if account_name.to_s.empty? + raise Supersaas::Exception, "API key is required" if api_key.to_s.empty? + raise Supersaas::Exception, "Host is required" if host.to_s.empty? + raise Supersaas::Exception, "Dry run must be boolean" unless [true, false].include?(dry_run) + raise Supersaas::Exception, "Verbose must be boolean" unless [true, false].include?(verbose) + end + end +end diff --git a/lib/supersaas-api-client/http_client.rb b/lib/supersaas-api-client/http_client.rb new file mode 100644 index 0000000..34b26d6 --- /dev/null +++ b/lib/supersaas-api-client/http_client.rb @@ -0,0 +1,201 @@ +# lib/supersaas-api-client/http_client.rb +module Supersaas + class HttpClient + DEFAULT_TIMEOUTS = { + open: 5, + read: 15, + write: 10 + }.freeze + + attr_reader :last_request + + def initialize(configuration, logger: Logger.new($stderr), max_retries: 2, **timeouts) + @config = configuration + @logger = logger + @max_retries = max_retries + @timeouts = DEFAULT_TIMEOUTS.merge(timeouts) + end + + def request(method, path, params = {}, query = {}) + validate_method!(method) + + uri = build_uri + http = create_http_connection(uri) + request = build_request(method, path, params, query) + @last_request = request + log_request(method, path, params) if @config.verbose + return {} if @config.dry_run + + execute_with_retries(http, request) + end + + private + + def validate_method!(method) + valid_methods = %i[get post put delete] + return if valid_methods.include?(method) + + raise Supersaas::Exception, "Invalid HTTP Method: #{method}. Only #{valid_methods.join(", ")} supported." + end + + def build_uri + if @config.host && !@config.host.empty? + URI.parse(@config.host) + else + URI.parse(Supersaas::Client.configuration&.host || Configuration::DEFAULT_HOST) + end + # host = @config.host.presence || Configuration::DEFAULT_HOST + # URI.parse(host) + end + + def create_http_connection(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @timeouts[:open] + http.read_timeout = @timeouts[:read] + http.write_timeout = @timeouts[:write] if http.respond_to?(:write_timeout) + http + end + + def build_request(method, path, params, query) + clean_params = delete_blank_values(params) + clean_query = delete_blank_values(query) + + full_path = build_path(path, clean_query) + request = Net::HTTP.const_get(method.capitalize).new(full_path) + + set_request_headers(request) + set_request_auth(request) + set_request_body(request, clean_params, method) + + request + end + + def build_path(path, query) + full_path = "/api#{path}.json" + full_path += "?#{URI.encode_www_form(query)}" if query.any? + full_path + end + + def set_request_headers(request) + request["Accept"] = "application/json" + request["Content-Type"] = "application/json" + request["User-Agent"] = Client.user_agent + end + + def set_request_auth(request) + request.basic_auth(@config.account_name, @config.api_key) + end + + def set_request_body(request, params, method) + request.body = params.to_json unless method == :get + end + + def execute_with_retries(http, request) + attempts = 0 + begin + attempts += 1 + response = http.request(request) + handle_response(response) + rescue *network_errors => e + retry if should_retry?(attempts, e) + raise Supersaas::Exception, "HTTP Request Error: #{e.message}" + end + end + + def network_errors + [ + Timeout::Error, Errno::ECONNRESET, EOFError, + Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, + Net::HTTPHeaderSyntaxError, Net::ProtocolError + ] + end + + def should_retry?(attempts, _error) + attempts <= @max_retries + end + + def handle_response(response) + log_response(response) if @config.verbose + + code = response.code.to_i + body = json_body(response) + + case code + when 200, 201 then handle_success_response(response, body) + when 400 then raise Supersaas::Exception, "Bad Request (400)" + when 401 then raise Supersaas::Exception, "Unauthorized (401)" + when 403 then raise Supersaas::Exception, "Forbidden (403)" + when 404 then raise Supersaas::Exception, "Not Found (404)" + when 405 then raise Supersaas::Exception, "Not available for capacity type schedule (405)" + when 409 then raise Supersaas::Exception, "Conflict (409)" + when 422 then raise Supersaas::Exception, "Unprocessable Entity (422): #{response.body}" + when 429 then raise Supersaas::Exception, "Too Many Requests (429)" + when 501 then raise Supersaas::Exception, "Not yet implemented for service type schedule (501)" + else raise Supersaas::Exception, "HTTP Request Error: #{code}" + end + end + + def handle_success_response(response, body) + if response["location"]&.include?("www.supersaas.com") + response["location"] + else + body + end + end + + def handle_errors(code, body) + log_errors(body) + case code + when 400 + raise Supersaas::Exception, "HTTP Request Error: Bad Request" + when 501 + raise Supersaas::Exception, "Not yet implemented for service type schedule" + when 405 + raise Supersaas::Exception, "Not available for capacity type schedule" + else + raise Supersaas::Exception, "HTTP Request Error: #{code}" + end + end + + def log_errors(body) + errors = body[:errors] || body["errors"] + return unless errors.is_a?(Array) + + errors.each do |error| + code = error[:code] || error["code"] + title = error[:title] || error["title"] + @logger.debug("Error code: #{code}, #{title}") + end + end + + def json_body(res) + return {} unless res.body&.size&.positive? + + JSON.parse(res.body, symbolize_names: true) + rescue JSON::ParserError => e + @logger.debug("Failed to parse JSON response: #{e.message}") + {} + end + + def delete_blank_values(hash) + return hash unless hash + + hash.dup.delete_if { |_k, v| v.nil? || v == "" || (v.is_a?(Hash) && v.compact.empty?) } + end + + def log_response(response) + @logger.info "Response:" + @logger.info response.inspect + @logger.info response.body + @logger.info "==============================" + end + + def log_request(method, path, params) + @logger.info "### SuperSaaS Client Request:" + @logger.info "#{method} #{path}" + @logger.info params.to_json + @logger.info "------------------------------" + end + end +end diff --git a/lib/supersaas-api-client/models/appointment.rb b/lib/supersaas-api-client/models/appointment.rb index 732bf3f..ac4c52d 100644 --- a/lib/supersaas-api-client/models/appointment.rb +++ b/lib/supersaas-api-client/models/appointment.rb @@ -3,9 +3,9 @@ module Supersaas class Appointment < BaseModel attr_accessor :field_1, :field_2, :field_2_r, :field_1_r, :country, :address, :mobile, :phone, :email, :price_cents, - :finish, :start, :service_name, :res_name, :id, :quantity, :status_message, :deleted, - :created_on, :slot_id, :created_by, :price, :status, :full_name, :super_field, :updated_by, :form_id, - :updated_on, :user_id, :waitlisted, :resource_id, :schedule_name, :schedule_id, :service_id + :finish, :start, :service_name, :res_name, :id, :quantity, :status_message, :deleted, + :created_on, :slot_id, :created_by, :price, :status, :full_name, :super_field, :updated_by, :form_id, + :updated_on, :user_id, :waitlisted, :resource_id, :schedule_name, :schedule_id, :service_id attr_reader :form, :slot diff --git a/lib/supersaas-api-client/models/user.rb b/lib/supersaas-api-client/models/user.rb index c0aae76..9cb5b8f 100644 --- a/lib/supersaas-api-client/models/user.rb +++ b/lib/supersaas-api-client/models/user.rb @@ -6,7 +6,7 @@ class User < BaseModel attr_reader :form attr_accessor :name, :email, :password, :full_name, :address, :mobile, :phone, :country, :timezone, :field_1, - :field_2, :super_field, :credit, :role, :group, :webhook, :id, :fk, :created_on, :updated_on + :field_2, :super_field, :credit, :role, :group, :webhook, :id, :fk, :created_on, :updated_on def form=(value) @form = value.is_a?(Hash) ? Supersaas::Form.new(value) : value diff --git a/lib/supersaas-api-client/rate_limiter.rb b/lib/supersaas-api-client/rate_limiter.rb new file mode 100644 index 0000000..48ef6ce --- /dev/null +++ b/lib/supersaas-api-client/rate_limiter.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Supersaas + class RateLimiter + WINDOW_SIZE = 1 # seconds + MAX_REQUESTS = 4 + + def initialize + @mutex = Mutex.new + @request_times = [] + end + + def throttle + @mutex.synchronize do + now = current_time + cleanup_old_requests(now) + wait_if_rate_limited(now) + record_request(now) + end + end + + private + + def current_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def cleanup_old_requests(now) + @request_times.shift while @request_times.any? && now - @request_times.first >= WINDOW_SIZE + end + + def wait_if_rate_limited(now) + return unless @request_times.size >= MAX_REQUESTS + + sleep_time = WINDOW_SIZE - (now - @request_times.first) + sleep(sleep_time) if sleep_time > 0 + cleanup_old_requests(current_time) + end + + def record_request(now) + @request_times << now + end + end +end diff --git a/lib/supersaas-api-client/version.rb b/lib/supersaas-api-client/version.rb index eb66cb0..71e891e 100644 --- a/lib/supersaas-api-client/version.rb +++ b/lib/supersaas-api-client/version.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module Supersaas - API_VERSION = '3' - VERSION = '2.0.5' + API_VERSION = "3" + VERSION = "2.0.5" end diff --git a/lib/supersaas.rb b/lib/supersaas.rb index a37a82c..520f6d0 100644 --- a/lib/supersaas.rb +++ b/lib/supersaas.rb @@ -1,23 +1,26 @@ # frozen_string_literal: true -require 'supersaas-api-client/version' -require 'supersaas-api-client/exception' -require 'supersaas-api-client/models/base_model' -require 'supersaas-api-client/models/appointment' -require 'supersaas-api-client/models/field_list' -require 'supersaas-api-client/models/form' -require 'supersaas-api-client/models/super_form' -require 'supersaas-api-client/models/group' -require 'supersaas-api-client/models/resource' -require 'supersaas-api-client/models/schedule' -require 'supersaas-api-client/models/promotion' -require 'supersaas-api-client/models/slot' -require 'supersaas-api-client/models/user' -require 'supersaas-api-client/api/base_api' -require 'supersaas-api-client/api/appointments' -require 'supersaas-api-client/api/forms' -require 'supersaas-api-client/api/schedules' -require 'supersaas-api-client/api/groups' -require 'supersaas-api-client/api/promotions' -require 'supersaas-api-client/api/users' -require 'supersaas-api-client/client' +require "supersaas-api-client/version" +require "supersaas-api-client/exception" +require "supersaas-api-client/models/base_model" +require "supersaas-api-client/models/appointment" +require "supersaas-api-client/models/field_list" +require "supersaas-api-client/models/form" +require "supersaas-api-client/models/super_form" +require "supersaas-api-client/models/group" +require "supersaas-api-client/models/resource" +require "supersaas-api-client/models/schedule" +require "supersaas-api-client/models/promotion" +require "supersaas-api-client/models/slot" +require "supersaas-api-client/models/user" +require "supersaas-api-client/api/base_api" +require "supersaas-api-client/api/appointments" +require "supersaas-api-client/api/forms" +require "supersaas-api-client/api/schedules" +require "supersaas-api-client/api/groups" +require "supersaas-api-client/api/promotions" +require "supersaas-api-client/api/users" +require "supersaas-api-client/client" +require "supersaas-api-client/configuration" +require "supersaas-api-client/http_client" +require "supersaas-api-client/rate_limiter" diff --git a/supersaas-api-client.gemspec b/supersaas-api-client.gemspec index e17388b..aa59ec3 100644 --- a/supersaas-api-client.gemspec +++ b/supersaas-api-client.gemspec @@ -1,28 +1,28 @@ # frozen_string_literal: true -$LOAD_PATH.push File.expand_path('lib', __dir__) +$LOAD_PATH.push File.expand_path("lib", __dir__) -require 'supersaas-api-client/version' +require "supersaas-api-client/version" Gem::Specification.new do |spec| - spec.name = 'supersaas-api-client' - spec.version = Supersaas::VERSION - spec.authors = ['Kaarle Kulvik'] - spec.email = ['kaarle@supersaas.com'] + spec.name = "supersaas-api-client" + spec.version = Supersaas::VERSION + spec.authors = ["Kaarle Kulvik"] + spec.email = ["kaarle@supersaas.com"] - spec.summary = 'Manage appointments and users on the SuperSaaS scheduling platform' - spec.description = 'Online appointment scheduling for any type of business. Flexible and affordable booking software that can be integrated into any site. Free basic version.' - spec.homepage = 'https://www.supersaas.com' - spec.license = 'MIT' + spec.summary = "Manage appointments and users on the SuperSaaS scheduling platform" + spec.description = "Online appointment scheduling for any type of business. Flexible and affordable booking software that can be integrated into any site. Free basic version." + spec.homepage = "https://www.supersaas.com" + spec.license = "MIT" - spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^test/}) } - spec.bindir = 'bin' - spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.require_paths = ['lib'] + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^test/}) } + spec.bindir = "bin" + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] - spec.add_development_dependency 'bundler' - spec.add_development_dependency 'minitest' - spec.add_development_dependency 'rake' - spec.add_development_dependency 'rubocop' - spec.metadata['rubygems_mfa_required'] = 'true' + spec.add_development_dependency "bundler" + spec.add_development_dependency "minitest" + spec.add_development_dependency "rake" + spec.add_development_dependency "rubocop" + spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/test/appointments_test.rb b/test/appointments_test.rb index 0597b99..6f3db23 100644 --- a/test/appointments_test.rb +++ b/test/appointments_test.rb @@ -1,14 +1,11 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class AppointmentsTest < SupersaasTest def setup - @client = Supersaas::Client.instance - @client.account_name = 'accnt' - @client.api_key = 'xxxxxxxxxxxxxxxxxxxxxx' - @client.dry_run = true + @client = client_instance @schedule_id = 12_345 @appointment_id = 67_890 @user_id = 9876 @@ -24,12 +21,12 @@ def test_list limit = 10 form = true refute_nil @client.appointments.list(@schedule_id, form, start_time, limit) - assert_last_request_path "/api/bookings.json?schedule_id=#{@schedule_id}&form=true&#{URI.encode_www_form(start: start_time.strftime('%Y-%m-%d %H:%M:%S'))}&limit=#{limit}" + assert_last_request_path "/api/bookings.json?schedule_id=#{@schedule_id}&form=true&#{URI.encode_www_form(start: start_time.strftime("%Y-%m-%d %H:%M:%S"))}&limit=#{limit}" end def test_create refute_nil @client.appointments.create(@schedule_id, @user_id, appointment_attributes, true, true) - assert_last_request_path '/api/bookings.json' + assert_last_request_path "/api/bookings.json" end def test_update @@ -50,26 +47,26 @@ def test_agenda_slots def test_available from_time = Time.now refute_nil @client.appointments.available(@schedule_id, from_time) - assert_last_request_path "/api/free/#{@schedule_id}.json?#{URI.encode_www_form(from: from_time.strftime('%Y-%m-%d %H:%M:%S'))}" + assert_last_request_path "/api/free/#{@schedule_id}.json?#{URI.encode_www_form(from: from_time.strftime("%Y-%m-%d %H:%M:%S"))}" end def test_available_full length_minutes = 15 - resource = 'MyResource' + resource = "MyResource" limit = 10 - refute_nil @client.appointments.available(@schedule_id, '2017-01-31 14:30:00', length_minutes, resource, true, - limit) - assert_last_request_path "/api/free/#{@schedule_id}.json?length=#{length_minutes}&#{URI.encode_www_form(from: '2017-01-31 14:30:00')}&resource=#{resource}&full=true&maxresults=#{limit}" + refute_nil @client.appointments.available(@schedule_id, "2017-01-31 14:30:00", length_minutes, resource, true, + limit) + assert_last_request_path "/api/free/#{@schedule_id}.json?length=#{length_minutes}&#{URI.encode_www_form(from: "2017-01-31 14:30:00")}&resource=#{resource}&full=true&maxresults=#{limit}" end def test_changes - from = '2017-01-31 14:30:00' + from = "2017-01-31 14:30:00" refute_nil @client.appointments.changes(@schedule_id, from) assert_last_request_path "/api/changes/#{@schedule_id}.json?#{URI.encode_www_form(from: from)}" end def test_range - from = '2017-01-31 14:30:00' + from = "2017-01-31 14:30:00" refute_nil @client.appointments.range(@schedule_id, false, from) assert_last_request_path "/api/range/#{@schedule_id}.json?#{URI.encode_www_form(from: from)}" end @@ -83,19 +80,19 @@ def test_delete def appointment_attributes { - description: 'Testing.', - name: 'Test', - email: 'test@example.com', - full_name: 'Tester Test', - address: '123 St, City', - mobile: '555-5555', - phone: '555-5555', - country: 'FR', - field_1: 'f 1', - field_2: 'f 2', - field_1_r: 'f 1 r', - field_2_r: 'f 2 r', - super_field: 'sf' + description: "Testing.", + name: "Test", + email: "test@example.com", + full_name: "Tester Test", + address: "123 St, City", + mobile: "555-5555", + phone: "555-5555", + country: "FR", + field_1: "f 1", + field_2: "f 2", + field_1_r: "f 1 r", + field_2_r: "f 2 r", + super_field: "sf" } end end diff --git a/test/client_test.rb b/test/client_test.rb index 08f4137..9dc6a57 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -1,14 +1,18 @@ # frozen_string_literal: true -require 'test_helper' -require 'time' +require "test_helper" +require "time" module Supersaas + # noinspection RubyNilAnalysis class ClientTest < SupersaasTest def setup - config = Supersaas::Client::Configuration.new - @client = Supersaas::Client.new(config) - @client.dry_run = true + @client = client_instance + end + + def teardown + # Clean up any client state between tests + @client = nil end def test_api @@ -19,43 +23,25 @@ def test_api end def test_request_methods - @client.account_name = 'Test' - @client.api_key = 'testing123' + @client.configuration.account_name = "Test" + @client.configuration.api_key = "testing123" %i[get put post delete].each do |method| - refute_nil @client.send(method, '/test') + refute_nil @client.send(method, "/test") assert_equal method.to_s.upcase, @client.last_request.method - assert_equal '/api/test.json', @client.last_request.path - end - assert_equal 'Basic VGVzdDp0ZXN0aW5nMTIz', @client.last_request['Authorization'] - assert_equal 'application/json', @client.last_request['Accept'] - assert_equal 'application/json', @client.last_request['Content-Type'] - end - - def test_instance_configuration - Supersaas::Client.configure do |config| - config.account_name = 'account' - config.api_key = 'api_key' - config.host = 'http://test' - config.dry_run = true - config.verbose = true + assert_equal "/api/test.json", @client.last_request.path end - assert_equal 'account', Supersaas::Client.configuration.account_name - assert_equal 'api_key', Supersaas::Client.configuration.api_key - assert_equal 'http://test', Supersaas::Client.configuration.host - assert_equal true, Supersaas::Client.configuration.dry_run - assert_equal true, Supersaas::Client.configuration.verbose + assert_equal "Basic VGVzdDp0ZXN0aW5nMTIz", @client.last_request["Authorization"] + assert_equal "application/json", @client.last_request["Accept"] + assert_equal "application/json", @client.last_request["Content-Type"] end def test_rate_limit - return unless ENV['SSS_RUBY_RATE_LIMITER_TEST'] == 'true' + return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - client = Supersaas::Client.new - client.account_name = 'test' - client.api_key = 'test' - client.dry_run = true + client = Supersaas::Client.new(@config) # Max burst allowed without errors - Client::MAX_REQUESTS.times do + RateLimiter::MAX_REQUESTS.times do start_time = Time.now client.send(:throttle) end_time = Time.now @@ -64,10 +50,10 @@ def test_rate_limit end # Wait for window to reset - sleep(Client::WINDOW_SIZE + 0.1) # Added a small buffer + sleep(RateLimiter::WINDOW_SIZE + 0.1) # Added a small buffer # Another burst of MAX_REQUESTS should now be allowed - Client::MAX_REQUESTS.times do + RateLimiter::MAX_REQUESTS.times do start_time = Time.now client.send(:throttle) end_time = Time.now @@ -76,7 +62,7 @@ def test_rate_limit end # Wait for window to expire and reset - sleep(Client::WINDOW_SIZE + 0.1) + sleep(RateLimiter::WINDOW_SIZE + 0.1) # Test longer throttling so that we don't get massive self DDOS start_time = Time.now @@ -87,5 +73,211 @@ def test_rate_limit elapsed_time = end_time - start_time assert_operator elapsed_time, :<, 4.1, "Expected throttling, #{elapsed_time} seconds" end + + def test_throttle_rate_limiting_mocked + client = Supersaas::Client.new(@config) + mock_time = 0 + + Process.stub(:clock_gettime, ->(clock_type) { mock_time }) do + # Test burst allowance + RateLimiter::MAX_REQUESTS.times do + client.send(:throttle) + mock_time += 0.1 # Simulate time progression + end + + # Verify throttling kicks in + assert_equal RateLimiter::MAX_REQUESTS, client.rate_limiter.instance_variable_get(:@request_times).size + end + end + + def test_throttle_thread_safety + return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" + + client = Supersaas::Client.new(@config) + + # Test concurrent access doesn't cause race conditions + threads = [] + results = Queue.new + + 5.times do + threads << Thread.new do + start_time = Time.now + RateLimiter::MAX_REQUESTS.times { client.send(:throttle) } + end_time = Time.now + results << (end_time - start_time) + end + end + + threads.each(&:join) + + # All threads should complete without errors + assert_equal 5, results.size + + # At least some threads should experience throttling + total_time = 0 + 5.times { total_time += results.pop } + assert_operator total_time, :>, 0, "Expected some throttling in concurrent scenario" + end + + def test_throttle_sliding_window + return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" + + # Create a fresh client to avoid interference from previous tests + client = Supersaas::Client.new(@config) + + # Fill up the rate limit + RateLimiter::MAX_REQUESTS.times do |i| + start_time = Time.now + client.send(:throttle) + elapsed = Time.now - start_time + assert_operator elapsed, :<, 0.1, "Request #{i + 1} of #{RateLimiter::MAX_REQUESTS} should not be throttled" + end + + # Next request should be throttled for approximately WINDOW_SIZE + start_time = Time.now + client.send(:throttle) + elapsed = Time.now - start_time + assert_operator elapsed, :>=, RateLimiter::WINDOW_SIZE - 0.2, "Request exceeding limit should be throttled" + + # Wait for window to partially expire + sleep(RateLimiter::WINDOW_SIZE / 2.0) + + # Make another request - should still experience some throttling + # since the window is sliding and some requests are still within the window + start_time = Time.now + client.send(:throttle) + elapsed = Time.now - start_time + + # The exact throttling time depends on sliding window cleanup, + # but it should be less than a full window since some time has passed + assert_operator elapsed, :<, RateLimiter::WINDOW_SIZE, "Sliding window should reduce throttling time" + end + + def test_throttle_mutex_synchronization + return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" + + # Create a fresh client to avoid interference from previous tests + client = Supersaas::Client.new(@config) + + # Make some requests sequentially + 3.times { client.send(:throttle) } + + request_times_after = client.rate_limiter.instance_variable_get(:@request_times) + assert_equal 3, request_times_after&.size, "Should track exactly 3 requests" + + # Verify all timestamps are recent using monotonic time + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + request_times_after.each do |timestamp| + assert_operator (now - timestamp), :<, 2, "All timestamps should be recent (within 2 seconds)" + end + + # Test actual mutex synchronization with concurrent access + threads = [] + errors = [] + + # Launch multiple threads that make requests concurrently + 5.times do + threads << Thread.new do + 2.times { client.send(:throttle) } + rescue => e + errors << e + end + end + + threads.each(&:join) + + # Verify no race conditions occurred + assert_empty errors, "No synchronization errors should occur" + + # Verify the request times array is in a consistent state + final_request_times = client.rate_limiter.instance_variable_get(:@request_times) + refute_nil final_request_times, "Request times should not be nil" + assert_operator final_request_times.size, :>, 0, "Should have recorded some requests" + + # Verify timestamps are monotonically ordered (no race conditions in array manipulation) + final_request_times.each_cons(2) do |earlier, later| + assert_operator earlier, :<=, later, "Timestamps should be in chronological order" + end + end + + def test_throttle_window_cleanup + return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" + + client = Supersaas::Client.new(@config) + + # Fill the window + RateLimiter::MAX_REQUESTS.times { client.send(:throttle) } + + request_times = client.rate_limiter.instance_variable_get(:@request_times) + assert_equal RateLimiter::MAX_REQUESTS, request_times.size + + # Wait for window to expire + sleep(RateLimiter::WINDOW_SIZE + 0.1) + + # Make another request - should clean up old entries + client.send(:throttle) + + request_times_after = client.rate_limiter.instance_variable_get(:@request_times) + assert_equal 1, request_times_after.size, "Old entries should be cleaned up" + end + + def test_throttle_constants + assert_equal 1, Supersaas::RateLimiter::WINDOW_SIZE, "Window size should be 1 second" + assert_equal 4, Supersaas::RateLimiter::MAX_REQUESTS, "Max requests should be 4" + end + + def test_json_body_symbolized_keys + response = Struct.new(:body).new('{"key": "value"}') + result = @client.http_client.send(:json_body, response) + assert_equal({key: "value"}, result) + end + + def test_log_errors_handles_both_key_types + # Create a logger that outputs to stdout for testing + string_io = StringIO.new + logger = Logger.new(string_io) + logger.level = Logger::DEBUG + + client = Supersaas::Client.new(@config, logger: logger) + + body_with_symbols = {errors: [{code: "123", title: "Test"}]} + body_with_strings = {"errors" => [{"code" => "456", "title" => "Test2"}]} + + client.http_client.send(:log_errors, body_with_symbols) + client.http_client.send(:log_errors, body_with_strings) + + output = string_io.string + assert_match(/Error code: 123/, output) + assert_match(/Error code: 456/, output) + end + + def test_delete_blank_values_immutable + original = {a: 1, b: nil, c: ""} + result = @client.http_client.send(:delete_blank_values, original) + + refute_same original, result + assert_equal({a: 1, b: nil, c: ""}, original) # Original unchanged + assert_equal({a: 1}, result) + end + + def test_throttle_thread_safety_deterministic + client = client_instance + barrier = Queue.new + errors = [] + + threads = 3.times.map do + Thread.new do + barrier.pop # Wait for all threads to be ready + 10.times { client.send(:throttle) } + rescue => e + errors << e + end + end + + 3.times { barrier.push(true) } # Release all threads + threads.each(&:join) + + assert_empty errors, "No thread safety errors should occur" + end end end diff --git a/test/forms_test.rb b/test/forms_test.rb index fab090f..6321a83 100644 --- a/test/forms_test.rb +++ b/test/forms_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class FormsTest < SupersaasTest @@ -12,13 +12,13 @@ def setup def test_list from = Time.now - refute_nil @client.forms.list(@super_form_id, from.strftime('%Y-%m-%d %H:%M:%S')) - assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{URI.encode_www_form(from: from.strftime('%Y-%m-%d %H:%M:%S'))}" + refute_nil @client.forms.list(@super_form_id, from.strftime("%Y-%m-%d %H:%M:%S")) + assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{URI.encode_www_form(from: from.strftime("%Y-%m-%d %H:%M:%S"))}" end def test_forms refute_nil @client.forms.forms - assert_last_request_path '/api/super_forms.json' + assert_last_request_path "/api/super_forms.json" end def test_get diff --git a/test/groups_test.rb b/test/groups_test.rb index 0cfcef5..0ef77de 100644 --- a/test/groups_test.rb +++ b/test/groups_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class GroupsTest < SupersaasTest @@ -10,7 +10,7 @@ def setup def test_list refute_nil @client.groups.list - assert_last_request_path '/api/groups.json' + assert_last_request_path "/api/groups.json" end end end diff --git a/test/promotions_test.rb b/test/promotions_test.rb index cdc0540..499a4f3 100644 --- a/test/promotions_test.rb +++ b/test/promotions_test.rb @@ -1,27 +1,27 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class PromotionsTest < SupersaasTest def setup @client = client_instance - @promotion_code = 'abc123' + @promotion_code = "abc123" end def test_promotion refute_nil @client.promotions.promotion(@promotion_code) - assert_last_request_path '/api/promotions.json?promotion_code=abc123' + assert_last_request_path "/api/promotions.json?promotion_code=abc123" end def test_list refute_nil @client.promotions.list - assert_last_request_path '/api/promotions.json' + assert_last_request_path "/api/promotions.json" end def test_duplicate_promotion_code - refute_nil @client.promotions.duplicate_promotion_code('new123', @promotion_code) - assert_last_request_path '/api/promotions.json' + refute_nil @client.promotions.duplicate_promotion_code("new123", @promotion_code) + assert_last_request_path "/api/promotions.json" end end end diff --git a/test/schedules_test.rb b/test/schedules_test.rb index 0a5efce..9df2175 100644 --- a/test/schedules_test.rb +++ b/test/schedules_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class SchedulesTest < SupersaasTest @@ -10,17 +10,17 @@ def setup def test_list refute_nil @client.schedules.list - assert_last_request_path '/api/schedules.json' + assert_last_request_path "/api/schedules.json" end def test_resources refute_nil @client.schedules.resources(12_345) - assert_last_request_path '/api/resources.json?schedule_id=12345' + assert_last_request_path "/api/resources.json?schedule_id=12345" end def test_field_list refute_nil @client.schedules.field_list(12_345) - assert_last_request_path '/api/field_list.json?schedule_id=12345' + assert_last_request_path "/api/field_list.json?schedule_id=12345" end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9cd1769..ab808d2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -$LOAD_PATH.push File.expand_path('../lib', __dir__) +$LOAD_PATH.push File.expand_path("../lib", __dir__) -require 'supersaas-api-client' +require "supersaas-api-client" -require 'minitest/autorun' +require "minitest/autorun" class SupersaasTest < Minitest::Test def assert_last_request_path(path) @@ -15,10 +15,12 @@ def assert_last_request_path(path) def client_instance unless defined? @client - @client = Supersaas::Client.instance - @client.account_name = 'accnt' - @client.api_key = 'xxxxxxxxxxxxxxxxxxxxxx' - @client.dry_run = true + @config = Supersaas::Configuration.new + + @config.account_name = "accnt" + @config.api_key = "xxxxxxxxxxxxxxxxxxxxxx" + @config.dry_run = true + @client = Supersaas::Client.instance(@config) end @client end diff --git a/test/users_test.rb b/test/users_test.rb index 9372132..6df7f31 100644 --- a/test/users_test.rb +++ b/test/users_test.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require 'test_helper' +require "test_helper" module Supersaas class UsersTest < SupersaasTest def setup @client = client_instance @user_id = 12_345 - @user_fk = '6789fk' + @user_fk = "6789fk" end def test_get @@ -29,7 +29,7 @@ def test_list def test_create refute_nil @client.users.create(user_attributes) - assert_last_request_path '/api/users.json' + assert_last_request_path "/api/users.json" end def test_create_fk @@ -49,24 +49,24 @@ def test_delete def test_field_list refute_nil @client.users.field_list - assert_last_request_path '/api/field_list.json' + assert_last_request_path "/api/field_list.json" end private def user_attributes { - name: 'Test', - email: 'test@example.com', - password: 'pass123', - full_name: 'Tester Test', - address: '123 St, City', - mobile: '555-5555', - phone: '555-5555', - country: 'FR', - field_1: 'f 1', - field_2: 'f 2', - super_field: 'sf', + name: "Test", + email: "test@example.com", + password: "pass123", + full_name: "Tester Test", + address: "123 St, City", + mobile: "555-5555", + phone: "555-5555", + country: "FR", + field_1: "f 1", + field_2: "f 2", + super_field: "sf", credit: 10, role: 3 } From 7f0131e530a13ad5be74beeda85eb25af2d83d6b Mon Sep 17 00:00:00 2001 From: jfromijn Date: Tue, 19 Aug 2025 03:45:16 +0200 Subject: [PATCH 2/4] initial rewrite refactor some files --- examples/appointments.rb | 4 +- lib/supersaas-api-client/client.rb | 49 +++- lib/supersaas-api-client/configuration.rb | 25 +- lib/supersaas-api-client/http_client.rb | 41 ++-- lib/supersaas-api-client/rate_limiter.rb | 18 +- test/client_test.rb | 266 ++-------------------- test/http_client_test.rb | 51 +++++ test/rate_limiter_test.rb | 123 ++++++++++ 8 files changed, 285 insertions(+), 292 deletions(-) create mode 100644 test/http_client_test.rb create mode 100644 test/rate_limiter_test.rb diff --git a/examples/appointments.rb b/examples/appointments.rb index c2f99d4..299d4a3 100755 --- a/examples/appointments.rb +++ b/examples/appointments.rb @@ -46,8 +46,8 @@ params[:slot_id] = ENV.fetch("SSS_API_SLOT", nil) else days = rand(1..30) - params[:start] = Time.now + (days * 24 * 60 * 60) - params[:finish] = params[:start] + (60 * 60) + params[:start] = (Time.now.to_i + (days * 86400)).to_s # 86400 seconds in a day + params[:finish] = (params[:start].to_i + 3600).to_s # 3600 seconds in an hour end puts "creating new appointment..." puts "#### Supersaas::Client.instance.appointments.create(#{schedule_id}, #{user_id}, {...})" diff --git a/lib/supersaas-api-client/client.rb b/lib/supersaas-api-client/client.rb index 92ec407..0225408 100644 --- a/lib/supersaas-api-client/client.rb +++ b/lib/supersaas-api-client/client.rb @@ -1,12 +1,7 @@ # frozen_string_literal: true -require "net/http" -require "uri" -require "json" -require "logger" -require "timeout" - module Supersaas + # noinspection RubyTooManyInstanceVariablesInspection class Client class << self attr_accessor :configuration @@ -15,8 +10,11 @@ def reset_instance! Thread.current["SUPER_SAAS_CLIENT"] = nil end - def instance(configuration = nil) - Thread.current["SUPER_SAAS_CLIENT"] ||= new(configuration || Configuration.new) + def instance(configuration = nil, **options) + if configuration + reset_instance! + end + Thread.current["SUPER_SAAS_CLIENT"] ||= new(configuration || Configuration.new, **options) end def user_agent @@ -31,8 +29,21 @@ def initialize(configuration = nil, **options) @configuration = configuration || Configuration.new @configuration.validate! + validate_options!(options) + @rate_limiter = RateLimiter.new + @http_client = HttpClient.new(@configuration, **options) + reset_service_objects + end + + def reload!(configuration: nil, **options) + @configuration = configuration || @configuration + @configuration.validate! + + validate_options!(options) @rate_limiter = RateLimiter.new @http_client = HttpClient.new(@configuration, **options) + reset_service_objects + self end def appointments @@ -63,22 +74,36 @@ def throttle rate_limiter.throttle end - %i[get post put delete].each do |method| + def get(path, query = {}) + request(:get, path, {}, query) + end + + %i[post put delete].each do |method| define_method(method) do |path, params = {}, query = {}| - params, query = {}, params if method == :get && query.empty? && !params.empty? request(method, path, params, query) end end + private # Sends an HTTP request using the specified method, path, params, and query. def request(method, path, params = {}, query = {}) rate_limiter.throttle - req = http_client.request(method, path, params, query) + resp = http_client.request(method, path, params, query) @last_request = http_client.last_request - req + resp + end + + def validate_options!(options) + valid_keys = %i[timeout open_timeout read_timeout write_timeout retries logger verbose dry_run] + invalid = options.keys - valid_keys + raise ArgumentError, "Unknown options: #{invalid.join(", ")}" unless invalid.empty? + end + + def reset_service_objects + @appointments = @forms = @schedules = @users = @promotions = @groups = nil end end end diff --git a/lib/supersaas-api-client/configuration.rb b/lib/supersaas-api-client/configuration.rb index ee8fc7f..2bc240b 100644 --- a/lib/supersaas-api-client/configuration.rb +++ b/lib/supersaas-api-client/configuration.rb @@ -15,15 +15,34 @@ def initialize end def valid? - !account_name.to_s.empty? && !api_key.to_s.empty? && !host.to_s.empty? + required_fields_present? && boolean_fields_valid? end def validate! raise Supersaas::Exception, "Account name is required" if account_name.to_s.empty? raise Supersaas::Exception, "API key is required" if api_key.to_s.empty? raise Supersaas::Exception, "Host is required" if host.to_s.empty? - raise Supersaas::Exception, "Dry run must be boolean" unless [true, false].include?(dry_run) - raise Supersaas::Exception, "Verbose must be boolean" unless [true, false].include?(verbose) + validate_host_format! + validate_boolean_fields! + end + + private + + def required_fields_present? + !account_name.to_s.empty? && !api_key.to_s.empty? && !host.to_s.empty? + end + + def boolean_fields_valid? + [dry_run, verbose].all? { |field| [true, false].include?(field) } + end + + def validate_host_format! + return if host =~ /\Ahttps?:\/\/.+/ + raise Supersaas::Exception, "Host must be a valid URL" + end + + def validate_boolean_fields! + raise Supersaas::Exception, "Dry run must be boolean" unless boolean_fields_valid? end end end diff --git a/lib/supersaas-api-client/http_client.rb b/lib/supersaas-api-client/http_client.rb index 34b26d6..7809485 100644 --- a/lib/supersaas-api-client/http_client.rb +++ b/lib/supersaas-api-client/http_client.rb @@ -1,4 +1,11 @@ # lib/supersaas-api-client/http_client.rb + +require "net/http" +require "uri" +require "json" +require "logger" +require "timeout" + module Supersaas class HttpClient DEFAULT_TIMEOUTS = { @@ -44,8 +51,6 @@ def build_uri else URI.parse(Supersaas::Client.configuration&.host || Configuration::DEFAULT_HOST) end - # host = @config.host.presence || Configuration::DEFAULT_HOST - # URI.parse(host) end def create_http_connection(uri) @@ -112,7 +117,13 @@ def network_errors end def should_retry?(attempts, _error) - attempts <= @max_retries + return false if attempts > @max_retries + + # Retry on network errors or specific HTTP status codes + return true if network_errors.any? { |err| error.is_a?(err) } + return true if error.is_a?(Supersaas::Exception) && error.message.include?("429") # Rate limit + + false end def handle_response(response) @@ -121,6 +132,7 @@ def handle_response(response) code = response.code.to_i body = json_body(response) + log_errors(body) if body[:errors] || body["errors"] case code when 200, 201 then handle_success_response(response, body) when 400 then raise Supersaas::Exception, "Bad Request (400)" @@ -144,20 +156,6 @@ def handle_success_response(response, body) end end - def handle_errors(code, body) - log_errors(body) - case code - when 400 - raise Supersaas::Exception, "HTTP Request Error: Bad Request" - when 501 - raise Supersaas::Exception, "Not yet implemented for service type schedule" - when 405 - raise Supersaas::Exception, "Not available for capacity type schedule" - else - raise Supersaas::Exception, "HTTP Request Error: #{code}" - end - end - def log_errors(body) errors = body[:errors] || body["errors"] return unless errors.is_a?(Array) @@ -169,10 +167,10 @@ def log_errors(body) end end - def json_body(res) - return {} unless res.body&.size&.positive? + def json_body(response) + return {} unless response.body&.size&.positive? - JSON.parse(res.body, symbolize_names: true) + JSON.parse(response.body, symbolize_names: true) rescue JSON::ParserError => e @logger.debug("Failed to parse JSON response: #{e.message}") {} @@ -181,7 +179,8 @@ def json_body(res) def delete_blank_values(hash) return hash unless hash - hash.dup.delete_if { |_k, v| v.nil? || v == "" || (v.is_a?(Hash) && v.compact.empty?) } + cleaned = hash.reject { |_k, v| v.nil? || v == "" || (v.is_a?(Hash) && v.compact.empty?) } + cleaned.empty? && !hash.empty? ? {} : cleaned end def log_response(response) diff --git a/lib/supersaas-api-client/rate_limiter.rb b/lib/supersaas-api-client/rate_limiter.rb index 48ef6ce..1a6cbc9 100644 --- a/lib/supersaas-api-client/rate_limiter.rb +++ b/lib/supersaas-api-client/rate_limiter.rb @@ -2,8 +2,9 @@ module Supersaas class RateLimiter - WINDOW_SIZE = 1 # seconds - MAX_REQUESTS = 4 + WINDOW_SIZE = 1.freeze # seconds + MAX_REQUESTS = 4.freeze + TIMING_TOLERANCE = 0.001.freeze # For floating point comparisons def initialize @mutex = Mutex.new @@ -32,9 +33,16 @@ def cleanup_old_requests(now) def wait_if_rate_limited(now) return unless @request_times.size >= MAX_REQUESTS - sleep_time = WINDOW_SIZE - (now - @request_times.first) - sleep(sleep_time) if sleep_time > 0 - cleanup_old_requests(current_time) + # Recalculate after potential cleanup + oldest_request = @request_times.first + sleep_time = WINDOW_SIZE - (now - oldest_request) + + if sleep_time > 0 + sleep(sleep_time) + # Update now after sleep and cleanup again + updated_now = current_time + cleanup_old_requests(updated_now) + end end def record_request(now) diff --git a/test/client_test.rb b/test/client_test.rb index 9dc6a57..aacc1cc 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -1,17 +1,15 @@ +# test/client_test.rb # frozen_string_literal: true require "test_helper" -require "time" module Supersaas - # noinspection RubyNilAnalysis class ClientTest < SupersaasTest def setup @client = client_instance end def teardown - # Clean up any client state between tests @client = nil end @@ -22,6 +20,21 @@ def test_api refute_nil @client.users end + def test_service_object_memoization + appointments = @client.appointments + assert_equal appointments, @client.appointments + end + + def test_reload_configuration + new_config = Configuration.new + new_config.account_name = "NewAccount" + new_config.api_key = "NewKey" + + @client.reload!(configuration: new_config) + assert_equal "NewAccount", @client.configuration.account_name + assert_equal "NewKey", @client.configuration.api_key + end + def test_request_methods @client.configuration.account_name = "Test" @client.configuration.api_key = "testing123" @@ -34,250 +47,5 @@ def test_request_methods assert_equal "application/json", @client.last_request["Accept"] assert_equal "application/json", @client.last_request["Content-Type"] end - - def test_rate_limit - return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - - client = Supersaas::Client.new(@config) - - # Max burst allowed without errors - RateLimiter::MAX_REQUESTS.times do - start_time = Time.now - client.send(:throttle) - end_time = Time.now - elapsed_time = end_time - start_time - assert_operator elapsed_time, :<, 0.1, "Expected no throttling, but got a delay of #{elapsed_time} seconds" - end - - # Wait for window to reset - sleep(RateLimiter::WINDOW_SIZE + 0.1) # Added a small buffer - - # Another burst of MAX_REQUESTS should now be allowed - RateLimiter::MAX_REQUESTS.times do - start_time = Time.now - client.send(:throttle) - end_time = Time.now - elapsed_time = end_time - start_time - assert_operator elapsed_time, :<, 0.1, "Expected no throttling, but got a delay of #{elapsed_time} seconds" - end - - # Wait for window to expire and reset - sleep(RateLimiter::WINDOW_SIZE + 0.1) - - # Test longer throttling so that we don't get massive self DDOS - start_time = Time.now - 20.times do - client.send(:throttle) - end - end_time = Time.now - elapsed_time = end_time - start_time - assert_operator elapsed_time, :<, 4.1, "Expected throttling, #{elapsed_time} seconds" - end - - def test_throttle_rate_limiting_mocked - client = Supersaas::Client.new(@config) - mock_time = 0 - - Process.stub(:clock_gettime, ->(clock_type) { mock_time }) do - # Test burst allowance - RateLimiter::MAX_REQUESTS.times do - client.send(:throttle) - mock_time += 0.1 # Simulate time progression - end - - # Verify throttling kicks in - assert_equal RateLimiter::MAX_REQUESTS, client.rate_limiter.instance_variable_get(:@request_times).size - end - end - - def test_throttle_thread_safety - return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - - client = Supersaas::Client.new(@config) - - # Test concurrent access doesn't cause race conditions - threads = [] - results = Queue.new - - 5.times do - threads << Thread.new do - start_time = Time.now - RateLimiter::MAX_REQUESTS.times { client.send(:throttle) } - end_time = Time.now - results << (end_time - start_time) - end - end - - threads.each(&:join) - - # All threads should complete without errors - assert_equal 5, results.size - - # At least some threads should experience throttling - total_time = 0 - 5.times { total_time += results.pop } - assert_operator total_time, :>, 0, "Expected some throttling in concurrent scenario" - end - - def test_throttle_sliding_window - return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - - # Create a fresh client to avoid interference from previous tests - client = Supersaas::Client.new(@config) - - # Fill up the rate limit - RateLimiter::MAX_REQUESTS.times do |i| - start_time = Time.now - client.send(:throttle) - elapsed = Time.now - start_time - assert_operator elapsed, :<, 0.1, "Request #{i + 1} of #{RateLimiter::MAX_REQUESTS} should not be throttled" - end - - # Next request should be throttled for approximately WINDOW_SIZE - start_time = Time.now - client.send(:throttle) - elapsed = Time.now - start_time - assert_operator elapsed, :>=, RateLimiter::WINDOW_SIZE - 0.2, "Request exceeding limit should be throttled" - - # Wait for window to partially expire - sleep(RateLimiter::WINDOW_SIZE / 2.0) - - # Make another request - should still experience some throttling - # since the window is sliding and some requests are still within the window - start_time = Time.now - client.send(:throttle) - elapsed = Time.now - start_time - - # The exact throttling time depends on sliding window cleanup, - # but it should be less than a full window since some time has passed - assert_operator elapsed, :<, RateLimiter::WINDOW_SIZE, "Sliding window should reduce throttling time" - end - - def test_throttle_mutex_synchronization - return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - - # Create a fresh client to avoid interference from previous tests - client = Supersaas::Client.new(@config) - - # Make some requests sequentially - 3.times { client.send(:throttle) } - - request_times_after = client.rate_limiter.instance_variable_get(:@request_times) - assert_equal 3, request_times_after&.size, "Should track exactly 3 requests" - - # Verify all timestamps are recent using monotonic time - now = Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_times_after.each do |timestamp| - assert_operator (now - timestamp), :<, 2, "All timestamps should be recent (within 2 seconds)" - end - - # Test actual mutex synchronization with concurrent access - threads = [] - errors = [] - - # Launch multiple threads that make requests concurrently - 5.times do - threads << Thread.new do - 2.times { client.send(:throttle) } - rescue => e - errors << e - end - end - - threads.each(&:join) - - # Verify no race conditions occurred - assert_empty errors, "No synchronization errors should occur" - - # Verify the request times array is in a consistent state - final_request_times = client.rate_limiter.instance_variable_get(:@request_times) - refute_nil final_request_times, "Request times should not be nil" - assert_operator final_request_times.size, :>, 0, "Should have recorded some requests" - - # Verify timestamps are monotonically ordered (no race conditions in array manipulation) - final_request_times.each_cons(2) do |earlier, later| - assert_operator earlier, :<=, later, "Timestamps should be in chronological order" - end - end - - def test_throttle_window_cleanup - return unless ENV["SSS_RUBY_RATE_LIMITER_TEST"] == "true" - - client = Supersaas::Client.new(@config) - - # Fill the window - RateLimiter::MAX_REQUESTS.times { client.send(:throttle) } - - request_times = client.rate_limiter.instance_variable_get(:@request_times) - assert_equal RateLimiter::MAX_REQUESTS, request_times.size - - # Wait for window to expire - sleep(RateLimiter::WINDOW_SIZE + 0.1) - - # Make another request - should clean up old entries - client.send(:throttle) - - request_times_after = client.rate_limiter.instance_variable_get(:@request_times) - assert_equal 1, request_times_after.size, "Old entries should be cleaned up" - end - - def test_throttle_constants - assert_equal 1, Supersaas::RateLimiter::WINDOW_SIZE, "Window size should be 1 second" - assert_equal 4, Supersaas::RateLimiter::MAX_REQUESTS, "Max requests should be 4" - end - - def test_json_body_symbolized_keys - response = Struct.new(:body).new('{"key": "value"}') - result = @client.http_client.send(:json_body, response) - assert_equal({key: "value"}, result) - end - - def test_log_errors_handles_both_key_types - # Create a logger that outputs to stdout for testing - string_io = StringIO.new - logger = Logger.new(string_io) - logger.level = Logger::DEBUG - - client = Supersaas::Client.new(@config, logger: logger) - - body_with_symbols = {errors: [{code: "123", title: "Test"}]} - body_with_strings = {"errors" => [{"code" => "456", "title" => "Test2"}]} - - client.http_client.send(:log_errors, body_with_symbols) - client.http_client.send(:log_errors, body_with_strings) - - output = string_io.string - assert_match(/Error code: 123/, output) - assert_match(/Error code: 456/, output) - end - - def test_delete_blank_values_immutable - original = {a: 1, b: nil, c: ""} - result = @client.http_client.send(:delete_blank_values, original) - - refute_same original, result - assert_equal({a: 1, b: nil, c: ""}, original) # Original unchanged - assert_equal({a: 1}, result) - end - - def test_throttle_thread_safety_deterministic - client = client_instance - barrier = Queue.new - errors = [] - - threads = 3.times.map do - Thread.new do - barrier.pop # Wait for all threads to be ready - 10.times { client.send(:throttle) } - rescue => e - errors << e - end - end - - 3.times { barrier.push(true) } # Release all threads - threads.each(&:join) - - assert_empty errors, "No thread safety errors should occur" - end end -end +end \ No newline at end of file diff --git a/test/http_client_test.rb b/test/http_client_test.rb new file mode 100644 index 0000000..58f93e6 --- /dev/null +++ b/test/http_client_test.rb @@ -0,0 +1,51 @@ +# test/http_client_test.rb +# frozen_string_literal: true + +require "test_helper" +require "stringio" +require "logger" + +module Supersaas + class HttpClientTest < SupersaasTest + def setup + @client = client_instance + end + + def teardown + @client = nil + end + + def test_json_body_symbolized_keys + response = Struct.new(:body).new('{"key": "value"}') + result = @client.http_client.send(:json_body, response) + assert_equal({ key: "value" }, result) + end + + def test_log_errors_handles_both_key_types + io = StringIO.new + logger = Logger.new(io) + logger.level = Logger::DEBUG + + client = Supersaas::Client.new(@config, logger: logger) + + body_with_symbols = { errors: [{ code: "123", title: "Test" }] } + body_with_strings = { "errors" => [{ "code" => "456", "title" => "Test2" }] } + + client.http_client.send(:log_errors, body_with_symbols) + client.http_client.send(:log_errors, body_with_strings) + + output = io.string + assert_match(/Error code: 123/, output) + assert_match(/Error code: 456/, output) + end + + def test_delete_blank_values_immutable + original = { a: 1, b: nil, c: "" } + result = @client.http_client.send(:delete_blank_values, original) + + refute_same original, result + assert_equal({ a: 1, b: nil, c: "" }, original) + assert_equal({ a: 1 }, result) + end + end +end \ No newline at end of file diff --git a/test/rate_limiter_test.rb b/test/rate_limiter_test.rb new file mode 100644 index 0000000..5988f2a --- /dev/null +++ b/test/rate_limiter_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "test_helper" + +module Supersaas + class RateLimiterTest < SupersaasTest + def setup + @client = client_instance + end + + def test_allows_max_requests_without_delay + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + RateLimiter::MAX_REQUESTS.times { @client.throttle } + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + assert_operator elapsed, :<, 0.05, "Should complete quickly within rate limit" + end + + def test_throttles_when_exceeding_rate_limit + RateLimiter::MAX_REQUESTS.times { @client.throttle } + + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @client.throttle + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + + assert_operator elapsed, :>=, 0.9, "Should throttle for nearly full window" + assert_operator elapsed, :<=, 1.1, "Should not over-throttle significantly" + end + + def test_request_times_tracking + initial_count = request_times_count + + @client.throttle + assert_equal initial_count + 1, request_times_count + + @client.throttle + assert_equal initial_count + 2, request_times_count + end + + def test_cleanup_removes_only_expired_requests + # Add some requests + 3.times { @client.throttle } + assert_equal 3, request_times_count + + # Manually add an old timestamp to test cleanup + old_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - (RateLimiter::WINDOW_SIZE + 1) + request_times = @client.rate_limiter.instance_variable_get(:@request_times) + request_times.unshift(old_time) + + # Trigger cleanup with new request + @client.throttle + + # Should have removed the old timestamp but kept recent ones + assert_equal 4, request_times_count, "Should keep recent requests and add new one" + end + + def test_thread_safety_maintains_consistency + request_count = 6 + threads = Array.new(3) do + Thread.new { 2.times { @client.throttle } } + end + + threads.each(&:join) + + final_count = request_times_count + assert_operator final_count, :<=, request_count, "Should not exceed expected request count" + assert_operator final_count, :>, 0, "Should have recorded some requests" + + # Verify timestamps are valid and ordered + times = @client.rate_limiter.instance_variable_get(:@request_times) + times.each_cons(2) do |earlier, later| + assert_operator earlier, :<=, later, "Timestamps should be ordered" + assert_kind_of Numeric, earlier + assert_kind_of Numeric, later + end + end + + def test_sliding_window_calculation + # Fill the limit + RateLimiter::MAX_REQUESTS.times { @client.throttle } + + # Get the oldest request time for calculation + oldest_time = @client.rate_limiter.instance_variable_get(:@request_times).first + current_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + # Calculate expected wait time + expected_wait = oldest_time + RateLimiter::WINDOW_SIZE - current_time + + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @client.throttle + actual_wait = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + + assert_in_delta expected_wait, actual_wait, 0.1, "Should wait for calculated sliding window time" + end + + def test_rate_limiter_constants + assert_equal 1, RateLimiter::WINDOW_SIZE + assert_equal 4, RateLimiter::MAX_REQUESTS + assert_instance_of Integer, RateLimiter::WINDOW_SIZE + assert_instance_of Integer, RateLimiter::MAX_REQUESTS + end + + def test_multiple_rapid_requests_spread_over_time + total_requests = RateLimiter::MAX_REQUESTS * 2 + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + total_requests.times { @client.throttle } + + total_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + minimum_expected = RateLimiter::WINDOW_SIZE # At least one full window + + assert_operator total_elapsed, :>=, minimum_expected - 0.1 + assert_operator request_times_count, :<=, RateLimiter::MAX_REQUESTS, "Should maintain window size" + end + + private + + def request_times_count + @client.rate_limiter.instance_variable_get(:@request_times)&.size || 0 + end + end +end \ No newline at end of file From 7fd1c3125659ec679837a3878288fd01a03078e1 Mon Sep 17 00:00:00 2001 From: jfromijn Date: Tue, 19 Aug 2025 04:36:37 +0200 Subject: [PATCH 3/4] add comprehensive test coverage and improve structure --- lib/supersaas-api-client/http_client.rb | 112 ++++++++++++++---------- test/appointments_test.rb | 53 +++++++++++ test/client_test.rb | 42 ++++++++- test/forms_test.rb | 54 ++++++++++++ test/http_client_test.rb | 85 +++++++++++++++--- test/rate_limiter_test.rb | 19 ++-- test/test_helper.rb | 28 +++--- 7 files changed, 312 insertions(+), 81 deletions(-) diff --git a/lib/supersaas-api-client/http_client.rb b/lib/supersaas-api-client/http_client.rb index 7809485..be6cace 100644 --- a/lib/supersaas-api-client/http_client.rb +++ b/lib/supersaas-api-client/http_client.rb @@ -1,4 +1,4 @@ -# lib/supersaas-api-client/http_client.rb +# frozen_string_literal: true require "net/http" require "uri" @@ -14,6 +14,12 @@ class HttpClient write: 10 }.freeze + NETWORK_ERRORS = [ + Timeout::Error, Errno::ECONNRESET, EOFError, + Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, + Net::HTTPHeaderSyntaxError, Net::ProtocolError + ].freeze + attr_reader :last_request def initialize(configuration, logger: Logger.new($stderr), max_retries: 2, **timeouts) @@ -30,6 +36,7 @@ def request(method, path, params = {}, query = {}) http = create_http_connection(uri) request = build_request(method, path, params, query) @last_request = request + log_request(method, path, params) if @config.verbose return {} if @config.dry_run @@ -38,6 +45,7 @@ def request(method, path, params = {}, query = {}) private + # Validation methods def validate_method!(method) valid_methods = %i[get post put delete] return if valid_methods.include?(method) @@ -45,12 +53,11 @@ def validate_method!(method) raise Supersaas::Exception, "Invalid HTTP Method: #{method}. Only #{valid_methods.join(", ")} supported." end + # URI and connection setup def build_uri - if @config.host && !@config.host.empty? - URI.parse(@config.host) - else - URI.parse(Supersaas::Client.configuration&.host || Configuration::DEFAULT_HOST) - end + host = @config.host&.empty? ? nil : @config.host + host ||= Supersaas::Client.configuration&.host || Configuration::DEFAULT_HOST + URI.parse(host) end def create_http_connection(uri) @@ -62,26 +69,31 @@ def create_http_connection(uri) http end + # Request building def build_request(method, path, params, query) clean_params = delete_blank_values(params) clean_query = delete_blank_values(query) - full_path = build_path(path, clean_query) - request = Net::HTTP.const_get(method.capitalize).new(full_path) - - set_request_headers(request) - set_request_auth(request) - set_request_body(request, clean_params, method) + request = Net::HTTP.const_get(method.capitalize).new(full_path) + configure_request(request, clean_params, method) request end def build_path(path, query) - full_path = "/api#{path}.json" + # Remove existing .json extension if present, then add it + clean_path = path.sub(/\.json$/, '') + full_path = "/api#{clean_path}.json" full_path += "?#{URI.encode_www_form(query)}" if query.any? full_path end + def configure_request(request, params, method) + set_request_headers(request) + set_request_auth(request) + set_request_body(request, params, method) + end + def set_request_headers(request) request["Accept"] = "application/json" request["Content-Type"] = "application/json" @@ -96,43 +108,48 @@ def set_request_body(request, params, method) request.body = params.to_json unless method == :get end + # Request execution and retry logic def execute_with_retries(http, request) attempts = 0 + begin attempts += 1 response = http.request(request) handle_response(response) - rescue *network_errors => e + rescue *NETWORK_ERRORS => e retry if should_retry?(attempts, e) raise Supersaas::Exception, "HTTP Request Error: #{e.message}" end end - def network_errors - [ - Timeout::Error, Errno::ECONNRESET, EOFError, - Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, - Net::HTTPHeaderSyntaxError, Net::ProtocolError - ] - end - - def should_retry?(attempts, _error) + def should_retry?(attempts, error) return false if attempts > @max_retries - - # Retry on network errors or specific HTTP status codes - return true if network_errors.any? { |err| error.is_a?(err) } - return true if error.is_a?(Supersaas::Exception) && error.message.include?("429") # Rate limit + return true if NETWORK_ERRORS.any? { |err| error.is_a?(err) } + return true if rate_limit_error?(error) false end + def rate_limit_error?(error) + error.is_a?(Supersaas::Exception) && error.message.include?("429") + end + + # Response handling def handle_response(response) log_response(response) if @config.verbose code = response.code.to_i body = json_body(response) - log_errors(body) if body[:errors] || body["errors"] + log_errors(body) if has_errors?(body) + process_status_code(code, response, body) + end + + def has_errors?(body) + body[:errors] || body["errors"] + end + + def process_status_code(code, response, body) case code when 200, 201 then handle_success_response(response, body) when 400 then raise Supersaas::Exception, "Bad Request (400)" @@ -149,24 +166,11 @@ def handle_response(response) end def handle_success_response(response, body) - if response["location"]&.include?("www.supersaas.com") - response["location"] - else - body - end - end - - def log_errors(body) - errors = body[:errors] || body["errors"] - return unless errors.is_a?(Array) - - errors.each do |error| - code = error[:code] || error["code"] - title = error[:title] || error["title"] - @logger.debug("Error code: #{code}, #{title}") - end + location = response["location"] + location&.include?("www.supersaas.com") ? location : body end + # Utility methods def json_body(response) return {} unless response.body&.size&.positive? @@ -179,10 +183,26 @@ def json_body(response) def delete_blank_values(hash) return hash unless hash - cleaned = hash.reject { |_k, v| v.nil? || v == "" || (v.is_a?(Hash) && v.compact.empty?) } + cleaned = hash.reject { |_k, v| blank_value?(v) } cleaned.empty? && !hash.empty? ? {} : cleaned end + def blank_value?(value) + value.nil? || value == "" || (value.is_a?(Hash) && value.compact.empty?) + end + + # Logging methods + def log_errors(body) + errors = body[:errors] || body["errors"] + return unless errors.is_a?(Array) + + errors.each do |error| + code = error[:code] || error["code"] + title = error[:title] || error["title"] + @logger.debug("Error code: #{code}, #{title}") + end + end + def log_response(response) @logger.info "Response:" @logger.info response.inspect @@ -197,4 +217,4 @@ def log_request(method, path, params) @logger.info "------------------------------" end end -end +end \ No newline at end of file diff --git a/test/appointments_test.rb b/test/appointments_test.rb index 6f3db23..8b07dd5 100644 --- a/test/appointments_test.rb +++ b/test/appointments_test.rb @@ -95,5 +95,58 @@ def appointment_attributes super_field: "sf" } end + + def test_create_with_time_calculations + days = 5 + start_time = Time.now + (days * 86400) + finish_time = start_time + 3600 + + params = appointment_attributes.merge( + start: start_time.to_s, + finish: finish_time.to_s + ) + + refute_nil @client.appointments.create(@schedule_id, @user_id, params, true, true) + assert_last_request_path "/api/bookings.json" + end + + def test_list_with_all_parameters + start_time = Time.now + limit = 25 + form = false + + refute_nil @client.appointments.list(@schedule_id, form, start_time, limit) + assert_last_request_path "/api/bookings.json?schedule_id=#{@schedule_id}&form=false&#{URI.encode_www_form(start: start_time.strftime("%Y-%m-%d %H:%M:%S"))}&limit=#{limit}" + end + + def test_changes_with_to_parameter + from = "2017-01-31 14:30:00" + to = "2017-02-28 23:59:59" + + refute_nil @client.appointments.changes(@schedule_id, from, to) + assert_last_request_path "/api/changes/#{@schedule_id}.json?#{URI.encode_www_form(from: from, to: to)}" + end + + def test_agenda_with_from_parameter + from_time = Time.now + + refute_nil @client.appointments.agenda(@schedule_id, @user_id, from_time) + assert_last_request_path "/api/agenda/#{@schedule_id}.json?user=#{@user_id}&#{URI.encode_www_form(from: from_time.strftime("%Y-%m-%d %H:%M:%S"))}" + end + + def test_create_with_slot_id + params = appointment_attributes.merge(slot_id: "12345") + + refute_nil @client.appointments.create(@schedule_id, @user_id, params) + assert_last_request_path "/api/bookings.json" + end + + def test_changes_with_datetime_objects + from = DateTime.now - 1 + to = DateTime.now + 1 + + refute_nil @client.appointments.changes(@schedule_id, from, to) + # Verify DateTime objects are properly converted to strings + end end end diff --git a/test/client_test.rb b/test/client_test.rb index aacc1cc..6565fef 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -1,4 +1,3 @@ -# test/client_test.rb # frozen_string_literal: true require "test_helper" @@ -13,36 +12,71 @@ def teardown @client = nil end - def test_api + # API service object tests + def test_api_service_objects_exist refute_nil @client.appointments refute_nil @client.forms refute_nil @client.schedules refute_nil @client.users + refute_nil @client.groups + refute_nil @client.promotions end def test_service_object_memoization appointments = @client.appointments assert_equal appointments, @client.appointments + + forms = @client.forms + assert_equal forms, @client.forms + + schedules = @client.schedules + assert_equal schedules, @client.schedules end + # Configuration tests def test_reload_configuration + original_account = @client.configuration.account_name + original_api_key = @client.configuration.api_key + new_config = Configuration.new new_config.account_name = "NewAccount" new_config.api_key = "NewKey" @client.reload!(configuration: new_config) + assert_equal "NewAccount", @client.configuration.account_name assert_equal "NewKey", @client.configuration.api_key + refute_equal original_account, @client.configuration.account_name + refute_equal original_api_key, @client.configuration.api_key end - def test_request_methods + # HTTP method tests + def test_http_methods_and_headers @client.configuration.account_name = "Test" @client.configuration.api_key = "testing123" + %i[get put post delete].each do |method| - refute_nil @client.send(method, "/test") + result = @client.send(method, "/test") + + refute_nil result assert_equal method.to_s.upcase, @client.last_request.method assert_equal "/api/test.json", @client.last_request.path end + + verify_request_headers + end + + def test_request_path_formatting + result = @client.get("/test") + assert_equal "/api/test.json", @client.last_request.path + + result = @client.get("/test.json") + assert_equal "/api/test.json", @client.last_request.path + end + + private + + def verify_request_headers assert_equal "Basic VGVzdDp0ZXN0aW5nMTIz", @client.last_request["Authorization"] assert_equal "application/json", @client.last_request["Accept"] assert_equal "application/json", @client.last_request["Content-Type"] diff --git a/test/forms_test.rb b/test/forms_test.rb index 6321a83..37f5351 100644 --- a/test/forms_test.rb +++ b/test/forms_test.rb @@ -16,6 +16,39 @@ def test_list assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{URI.encode_www_form(from: from.strftime("%Y-%m-%d %H:%M:%S"))}" end + def test_list_with_user_parameter + from = Time.now + user_id = 12345 + + refute_nil @client.forms.list(@super_form_id, from.strftime("%Y-%m-%d %H:%M:%S"), user_id) + assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{URI.encode_www_form(from: from.strftime("%Y-%m-%d %H:%M:%S"), user: user_id)}" + end + + def test_list_with_limit_and_offset + from = Time.now + limit = 25 + offset = 50 + + refute_nil @client.forms.list(@super_form_id, from.strftime("%Y-%m-%d %H:%M:%S"), nil, limit, offset) + assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{URI.encode_www_form(from: from.strftime("%Y-%m-%d %H:%M:%S"), limit: limit, offset: offset)}" + end + + def test_list_with_all_parameters + from = Time.now + user_id = 12345 + limit = 25 + offset = 50 + + refute_nil @client.forms.list(@super_form_id, from.strftime("%Y-%m-%d %H:%M:%S"), user_id, limit, offset) + expected_params = URI.encode_www_form( + from: from.strftime("%Y-%m-%d %H:%M:%S"), + user: user_id, + limit: limit, + offset: offset + ) + assert_last_request_path "/api/forms.json?form_id=#{@super_form_id}&#{expected_params}" + end + def test_forms refute_nil @client.forms.forms assert_last_request_path "/api/super_forms.json" @@ -25,5 +58,26 @@ def test_get refute_nil @client.forms.get(@form_id) assert_last_request_path "/api/forms.json?id=#{@form_id}" end + + def test_list_returns_form_objects + result = @client.forms.list(@super_form_id) + assert result.is_a?(Array) + result.each do |form| + assert form.is_a?(Supersaas::Form) + end + end + + def test_get_returns_form_object + result = @client.forms.get(@form_id) + assert result.is_a?(Supersaas::Form) + end + + def test_forms_returns_super_form_objects + result = @client.forms.forms + assert result.is_a?(Array) + result.each do |form| + assert form.is_a?(Supersaas::SuperForm) + end + end end end diff --git a/test/http_client_test.rb b/test/http_client_test.rb index 58f93e6..64f8b3a 100644 --- a/test/http_client_test.rb +++ b/test/http_client_test.rb @@ -1,4 +1,3 @@ -# test/http_client_test.rb # frozen_string_literal: true require "test_helper" @@ -15,21 +14,45 @@ def teardown @client = nil end - def test_json_body_symbolized_keys - response = Struct.new(:body).new('{"key": "value"}') + # JSON response handling tests + def test_json_body_symbolizes_keys + response = create_mock_response('{"key": "value"}') + result = @client.http_client.send(:json_body, response) + assert_equal({ key: "value" }, result) end - def test_log_errors_handles_both_key_types + # Error logging tests + def test_log_errors_handles_symbolized_keys io = StringIO.new - logger = Logger.new(io) - logger.level = Logger::DEBUG + logger = create_test_logger(io) + client = create_client_with_logger(logger) + body = { errors: [{ code: "123", title: "Test Error" }] } + + client.http_client.send(:log_errors, body) - client = Supersaas::Client.new(@config, logger: logger) + assert_logged_error(io, "123") + end + + def test_log_errors_handles_string_keys + io = StringIO.new + logger = create_test_logger(io) + client = create_client_with_logger(logger) + body = { "errors" => [{ "code" => "456", "title" => "Test Error 2" }] } - body_with_symbols = { errors: [{ code: "123", title: "Test" }] } - body_with_strings = { "errors" => [{ "code" => "456", "title" => "Test2" }] } + client.http_client.send(:log_errors, body) + + assert_logged_error(io, "456") + end + + def test_log_errors_handles_mixed_key_types + io = StringIO.new + logger = create_test_logger(io) + client = create_client_with_logger(logger) + + body_with_symbols = { errors: [{ code: "123", title: "Symbol Test" }] } + body_with_strings = { "errors" => [{ "code" => "456", "title" => "String Test" }] } client.http_client.send(:log_errors, body_with_symbols) client.http_client.send(:log_errors, body_with_strings) @@ -39,13 +62,53 @@ def test_log_errors_handles_both_key_types assert_match(/Error code: 456/, output) end - def test_delete_blank_values_immutable + # Data cleaning tests + def test_delete_blank_values_removes_nil_and_empty_strings + original = { a: 1, b: nil, c: "", d: "value" } + + result = @client.http_client.send(:delete_blank_values, original) + + assert_equal({ a: 1, d: "value" }, result) + end + + def test_delete_blank_values_preserves_original_hash original = { a: 1, b: nil, c: "" } + result = @client.http_client.send(:delete_blank_values, original) refute_same original, result assert_equal({ a: 1, b: nil, c: "" }, original) - assert_equal({ a: 1 }, result) + end + + def test_delete_blank_values_handles_empty_hash + original = {} + + result = @client.http_client.send(:delete_blank_values, original) + + assert_equal({}, result) + refute_same original, result + end + + private + + def create_mock_response(body) + Struct.new(:body).new(body) + end + + def create_test_logger(io) + logger = Logger.new(io) + logger.level = Logger::DEBUG + + logger + end + + def create_client_with_logger(logger) + config = @client.configuration.dup + Supersaas::Client.new(config, logger: logger) + end + + def assert_logged_error(logger, error_code) + assert_match(/Error code: #{error_code}/, logger.string) end end end \ No newline at end of file diff --git a/test/rate_limiter_test.rb b/test/rate_limiter_test.rb index 5988f2a..7bc30ad 100644 --- a/test/rate_limiter_test.rb +++ b/test/rate_limiter_test.rb @@ -8,6 +8,7 @@ def setup @client = client_instance end + # Basic rate limiting behavior tests def test_allows_max_requests_without_delay start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @@ -28,6 +29,7 @@ def test_throttles_when_exceeding_rate_limit assert_operator elapsed, :<=, 1.1, "Should not over-throttle significantly" end + # Request tracking tests def test_request_times_tracking initial_count = request_times_count @@ -55,6 +57,7 @@ def test_cleanup_removes_only_expired_requests assert_equal 4, request_times_count, "Should keep recent requests and add new one" end + # Thread safety tests def test_thread_safety_maintains_consistency request_count = 6 threads = Array.new(3) do @@ -76,6 +79,7 @@ def test_thread_safety_maintains_consistency end end + # Sliding window algorithm tests def test_sliding_window_calculation # Fill the limit RateLimiter::MAX_REQUESTS.times { @client.throttle } @@ -94,13 +98,6 @@ def test_sliding_window_calculation assert_in_delta expected_wait, actual_wait, 0.1, "Should wait for calculated sliding window time" end - def test_rate_limiter_constants - assert_equal 1, RateLimiter::WINDOW_SIZE - assert_equal 4, RateLimiter::MAX_REQUESTS - assert_instance_of Integer, RateLimiter::WINDOW_SIZE - assert_instance_of Integer, RateLimiter::MAX_REQUESTS - end - def test_multiple_rapid_requests_spread_over_time total_requests = RateLimiter::MAX_REQUESTS * 2 start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @@ -114,6 +111,14 @@ def test_multiple_rapid_requests_spread_over_time assert_operator request_times_count, :<=, RateLimiter::MAX_REQUESTS, "Should maintain window size" end + # Configuration tests + def test_rate_limiter_constants + assert_equal 1, RateLimiter::WINDOW_SIZE + assert_equal 4, RateLimiter::MAX_REQUESTS + assert_instance_of Integer, RateLimiter::WINDOW_SIZE + assert_instance_of Integer, RateLimiter::MAX_REQUESTS + end + private def request_times_count diff --git a/test/test_helper.rb b/test/test_helper.rb index ab808d2..6a12fab 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,25 +3,27 @@ $LOAD_PATH.push File.expand_path("../lib", __dir__) require "supersaas-api-client" - require "minitest/autorun" class SupersaasTest < Minitest::Test + protected + def assert_last_request_path(path) assert_equal path, @client.last_request.path end - protected - def client_instance - unless defined? @client - @config = Supersaas::Configuration.new - - @config.account_name = "accnt" - @config.api_key = "xxxxxxxxxxxxxxxxxxxxxx" - @config.dry_run = true - @client = Supersaas::Client.instance(@config) - end - @client + @client ||= create_test_client + end + + private + + def create_test_client + config = Supersaas::Configuration.new + config.account_name = "accnt" + config.api_key = "xxxxxxxxxxxxxxxxxxxxxx" + config.dry_run = true + + Supersaas::Client.instance(config) end -end +end \ No newline at end of file From b91dc2f5d1f708818fd33d9c99573d980ccc7ab2 Mon Sep 17 00:00:00 2001 From: jfromijn Date: Tue, 19 Aug 2025 06:01:08 +0200 Subject: [PATCH 4/4] expand test suite to cover http client edge cases and improve request path test --- examples/appointments.rb | 1 - test/client_test.rb | 4 +- test/http_client_test.rb | 94 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/examples/appointments.rb b/examples/appointments.rb index 299d4a3..835e465 100755 --- a/examples/appointments.rb +++ b/examples/appointments.rb @@ -37,7 +37,6 @@ puts "#New user created #{user_id}" end -description = nil if user_id description = "1234567890." params = {full_name: "Example", description: description, name: "example@example.com", email: "example@example.com", diff --git a/test/client_test.rb b/test/client_test.rb index 6565fef..fb929da 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -67,10 +67,10 @@ def test_http_methods_and_headers end def test_request_path_formatting - result = @client.get("/test") + @client.get("/test") assert_equal "/api/test.json", @client.last_request.path - result = @client.get("/test.json") + @client.get("/test.json") assert_equal "/api/test.json", @client.last_request.path end diff --git a/test/http_client_test.rb b/test/http_client_test.rb index 64f8b3a..078bf1b 100644 --- a/test/http_client_test.rb +++ b/test/http_client_test.rb @@ -89,6 +89,77 @@ def test_delete_blank_values_handles_empty_hash refute_same original, result end + def test_retry_on_network_errors + http = mock_http_with_timeout_error + request = create_mock_request + + assert_raises(Supersaas::Exception) do + @client.http_client.send(:execute_with_retries, http, request) + end + end + + def test_should_retry_logic + client = @client.http_client + + # Should retry on first attempt + assert client.send(:should_retry?, 1, Timeout::Error.new) + + # Should not retry after max attempts + refute client.send(:should_retry?, 3, Timeout::Error.new) + end + + def test_process_status_code_handles_all_error_codes + client = @client.http_client + mock_response = create_mock_response("error body") + + [400, 401, 403, 404, 405, 409, 422, 429, 501].each do |code| + assert_raises(Supersaas::Exception) do + client.send(:process_status_code, code, mock_response, {}) + end + end + end + + def test_handle_success_response_with_location_header + response = mock_response_with_location + body = { id: 123 } + + result = @client.http_client.send(:handle_success_response, response, body) + + assert_equal "https://www.supersaas.com/app/123", result + end + + def test_http_client_initialization_with_custom_timeouts + config = @client.configuration + custom_timeouts = { open: 10, read: 30 } + + http_client = HttpClient.new(config, **custom_timeouts) + + assert_equal 10, http_client.instance_variable_get(:@timeouts)[:open] + assert_equal 30, http_client.instance_variable_get(:@timeouts)[:read] + end + + def test_json_body_handles_malformed_json + original_level = @client.http_client.instance_variable_get(:@logger).level + @client.http_client.instance_variable_get(:@logger).level = Logger::FATAL + response = create_mock_response('{"invalid": json}') + + result = @client.http_client.send(:json_body, response) + + assert_equal({}, result) + ensure + @client.http_client.instance_variable_get(:@logger).level = original_level + end + + def test_blank_value_detection + client = @client.http_client + + assert client.send(:blank_value?, nil) + assert client.send(:blank_value?, "") + assert client.send(:blank_value?, {}) + refute client.send(:blank_value?, "value") + refute client.send(:blank_value?, 0) + end + private def create_mock_response(body) @@ -110,5 +181,28 @@ def create_client_with_logger(logger) def assert_logged_error(logger, error_code) assert_match(/Error code: #{error_code}/, logger.string) end + + def mock_http_with_timeout_error + http = Minitest::Mock.new + + def http.request(_request) + raise Timeout::Error + end + + http + end + + def create_mock_request + Net::HTTP::Get.new("/test") + end + + def mock_response_with_location + response = Struct.new(:code, :body) do + def [](key) + key == "location" ? "https://www.supersaas.com/app/123" : nil + end + end + response.new(200, "") + end end end \ No newline at end of file