# frozen_string_literal: true # Drop-in Ruby client library for the KI BMS HTTP API. # # Save this file under your project as `ats_client.rb` and require # it directly: # # require_relative 'ats_client' # c = AtsClient::Client.new('pat_...') # rows = c.account_list(limit: 20, sort: '-created_at') # fresh = c.account_create({ 'name' => 'Example GmbH' }) # # Every endpoint exposed by the HTTP API is wrapped as an # `_` instance method on Client. List methods take keyword # args; get/update/delete methods take the row id as their first # positional argument. # # Provided as-is, with no warranty. Vendor freely; modify as needed. # Targets Ruby 3.0+; uses only the stdlib (`net/http`, `json`, # `securerandom`, `uri`). # # DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. # Local edits will be overwritten by the once-per-day version check. require 'net/http' require 'uri' require 'json' require 'securerandom' require 'fileutils' module AtsClient APP_SLUG = 'ats' APP_NAME = 'KI BMS' MODULE_NAME = 'ats_client' CLIENT_VERSION = '0.3.13' LANGUAGE = 'ruby' DEFAULT_BASE = 'https://www.ki-bewerber-management.de' # Per-type metadata baked at generation time. Inspect with JSON.parse # if you need the legal filters / sorts / max_limit per model without # an extra round-trip. TYPES_JSON = <<~'JSON_BLOB' {"application":{"ops":["list","read","create","update","delete"],"create_fields":["job_id","candidate_id","stage","previous_stage","position","applied_at","last_stage_at","source_id","source_label","cover_letter","cv_blob_id","cv_url","answers","fit_score","fit_reasoning","fit_flags","fit_computed_at","rejected_reason","rejected_note","tags"],"update_fields":["stage","previous_stage","position","applied_at","last_stage_at","source_id","source_label","cover_letter","cv_blob_id","cv_url","answers","fit_score","fit_reasoning","fit_flags","fit_computed_at","rejected_reason","rejected_note","tags"],"allowed_filters":["data__job_id","data__candidate_id","data__stage","data__source_id","data__rejected_reason","data__is_archived","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__applied_at","data__fit_score","data__position","data__last_stage_at"],"default_sort":"data__position","max_limit":500,"fields":[{"name":"tags","type":"tags"},{"name":"stage","type":"enum","values":["new","review","screening","interview","offer","hired","rejected","talent_pool"]},{"name":"cv_url","type":"url","max_len":2048},{"name":"job_id","type":"string","max_len":64,"ref":{"type":"job","owned":false,"optional":false}},{"name":"answers","type":"list"},{"name":"position","type":"number"},{"name":"fit_flags","type":"tags"},{"name":"fit_score","type":"number"},{"name":"source_id","type":"string","max_len":64,"ref":{"type":"source","owned":false,"optional":false}},{"name":"applied_at","type":"string","max_len":32},{"name":"cv_blob_id","type":"string","max_len":64},{"name":"candidate_id","type":"string","max_len":64,"ref":{"type":"candidate","owned":false,"optional":false}},{"name":"cover_letter","type":"string","max_len":16000},{"name":"source_label","type":"string","max_len":200},{"name":"fit_reasoning","type":"string","max_len":4000},{"name":"last_stage_at","type":"string","max_len":32},{"name":"rejected_note","type":"string","max_len":2000},{"name":"previous_stage","type":"string","max_len":32},{"name":"fit_computed_at","type":"string","max_len":32},{"name":"rejected_reason","type":"enum","values":["not_qualified","salary_mismatch","location_mismatch","culture_mismatch","withdrew","ghosted","filled_internally","duplicate","other"]}]},"application_note":{"ops":["list","read","create","update","delete"],"create_fields":["body","pinned","private","parent_kind","parent_id"],"update_fields":["body","pinned","private"],"allowed_filters":["data__parent_id","data__parent_kind","data__pinned","data__private","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":8000},{"name":"pinned","type":"bool"},{"name":"private","type":"bool"},{"name":"parent_id","type":"string","max_len":64},{"name":"parent_kind","type":"enum","values":["candidate","application","job"]}]},"candidate":{"ops":["list","read","create","update","delete"],"create_fields":["name","first_name","last_name","salutation","pronouns","email","phone","city","country","current_company","current_role","years_experience","available_from","salary_expectation","currency","linkedin","github","portfolio","cv_url","cv_blob_id","avatar_blob_id","summary","skills","languages","tags","source_id","source_label","pool_status","gdpr_consent","gdpr_consent_at","gdpr_retention_until","preferred_locale","last_touched_at","color"],"update_fields":["name","first_name","last_name","salutation","pronouns","email","phone","city","country","current_company","current_role","years_experience","available_from","salary_expectation","currency","linkedin","github","portfolio","cv_url","cv_blob_id","avatar_blob_id","summary","skills","languages","tags","source_id","source_label","pool_status","gdpr_consent","gdpr_consent_at","gdpr_retention_until","preferred_locale","last_touched_at","color"],"allowed_filters":["data__email","data__name","data__location","data__country","data__source_id","data__tags","data__skills","data__pool_status","data__gdpr_consent","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__name","data__last_touched_at"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"city","type":"string","max_len":120},{"name":"name","type":"string","max_len":200},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"email","type":"string","max_len":320},{"name":"phone","type":"string","max_len":64},{"name":"cv_url","type":"url","max_len":2048},{"name":"github","type":"url","max_len":2048},{"name":"skills","type":"tags"},{"name":"country","type":"string","max_len":120},{"name":"summary","type":"string","max_len":4000},{"name":"currency","type":"string","max_len":8},{"name":"linkedin","type":"url","max_len":2048},{"name":"pronouns","type":"string","max_len":32},{"name":"languages","type":"tags"},{"name":"last_name","type":"string","max_len":120},{"name":"portfolio","type":"url","max_len":2048},{"name":"source_id","type":"string","max_len":64,"ref":{"type":"source","owned":false,"optional":false}},{"name":"cv_blob_id","type":"string","max_len":64},{"name":"first_name","type":"string","max_len":120},{"name":"salutation","type":"enum","values":["herr","frau","divers","neutral"]},{"name":"pool_status","type":"enum","values":["active","talent_pool","blocked","withdrawn"]},{"name":"current_role","type":"string","max_len":200},{"name":"gdpr_consent","type":"bool"},{"name":"source_label","type":"string","max_len":200},{"name":"available_from","type":"string","max_len":32},{"name":"avatar_blob_id","type":"string","max_len":64},{"name":"current_company","type":"string","max_len":200},{"name":"gdpr_consent_at","type":"string","max_len":32},{"name":"last_touched_at","type":"string","max_len":32},{"name":"preferred_locale","type":"string","max_len":16},{"name":"years_experience","type":"number"},{"name":"salary_expectation","type":"number"},{"name":"gdpr_retention_until","type":"string","max_len":32}]},"email_template":{"ops":["list","read","create","update","delete"],"create_fields":["name","category","subject","body","language","stage_trigger","auto_send","active","variables_doc"],"update_fields":["name","category","subject","body","language","stage_trigger","auto_send","active","variables_doc"],"allowed_filters":["data__name","data__category","data__stage_trigger","data__active","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__name"],"default_sort":"data__name","max_limit":100,"fields":[{"name":"body","type":"string","max_len":16000},{"name":"name","type":"string","max_len":200},{"name":"active","type":"bool"},{"name":"subject","type":"string","max_len":400},{"name":"category","type":"enum","values":["acknowledge","screening_invite","interview_invite","rejection","offer","talent_pool","other"]},{"name":"language","type":"string","max_len":16},{"name":"auto_send","type":"bool"},{"name":"stage_trigger","type":"enum","values":["","new","review","screening","interview","offer","hired","rejected","talent_pool"]},{"name":"variables_doc","type":"string","max_len":2000}]},"evaluation":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","interview_id","interviewer_id","skills_score","culture_score","communication_score","potential_score","overall_score","recommendation","highlights","concerns","summary"],"update_fields":["skills_score","culture_score","communication_score","potential_score","overall_score","recommendation","highlights","concerns","summary"],"allowed_filters":["data__application_id","data__interview_id","data__interviewer_id","data__recommendation","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__overall_score"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"summary","type":"string","max_len":4000},{"name":"concerns","type":"string","max_len":4000},{"name":"highlights","type":"string","max_len":4000},{"name":"interview_id","type":"string","max_len":64,"ref":{"type":"interview","owned":false,"optional":false}},{"name":"skills_score","type":"number"},{"name":"culture_score","type":"number"},{"name":"overall_score","type":"number"},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"interviewer_id","type":"string","max_len":64},{"name":"recommendation","type":"enum","values":["strong_yes","yes","neutral","no","strong_no"]},{"name":"potential_score","type":"number"},{"name":"communication_score","type":"number"}]},"interview":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","candidate_id","job_id","kind","status","title","scheduled_at","duration_minutes","location","meeting_url","interviewer_id","interviewer_ids","agenda","notes","send_invite"],"update_fields":["kind","status","title","scheduled_at","duration_minutes","location","meeting_url","interviewer_id","interviewer_ids","agenda","notes","send_invite"],"allowed_filters":["data__application_id","data__candidate_id","data__job_id","data__kind","data__status","data__interviewer_id","status","is_archived","owned_by","created_by"],"allowed_sorts":["data__scheduled_at","created_at","updated_at"],"default_sort":"data__scheduled_at","max_limit":200,"fields":[{"name":"kind","type":"enum","values":["phone","video","onsite","take_home","panel","trial_day"]},{"name":"notes","type":"string","max_len":8000},{"name":"title","type":"string","max_len":200},{"name":"agenda","type":"string","max_len":4000},{"name":"job_id","type":"string","max_len":64},{"name":"status","type":"enum","values":["scheduled","completed","no_show","cancelled","rescheduled"]},{"name":"location","type":"string","max_len":200},{"name":"meeting_url","type":"url","max_len":2048},{"name":"send_invite","type":"bool"},{"name":"candidate_id","type":"string","max_len":64},{"name":"scheduled_at","type":"string","max_len":32},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"interviewer_id","type":"string","max_len":64},{"name":"interviewer_ids","type":"list"},{"name":"duration_minutes","type":"number"}]},"job":{"ops":["list","read","create","update","delete"],"create_fields":["title","slug","department","location","country","remote","employment_type","seniority","headcount","salary_min","salary_max","currency","salary_visibility","summary","description","responsibilities","requirements","nice_to_have","benefits","language","status","public","ai_screen_enabled","ai_screen_prompt","knockout_questions","screening_questions","tags","hiring_manager_id","team_ids","opened_at","target_close_date","closed_at","external_apply_url","color"],"update_fields":["title","slug","department","location","country","remote","employment_type","seniority","headcount","salary_min","salary_max","currency","salary_visibility","summary","description","responsibilities","requirements","nice_to_have","benefits","language","status","public","ai_screen_enabled","ai_screen_prompt","knockout_questions","screening_questions","tags","hiring_manager_id","team_ids","opened_at","target_close_date","closed_at","external_apply_url","color"],"allowed_filters":["data__title","data__department","data__location","data__employment_type","data__seniority","data__remote","data__status","data__public","data__hiring_manager_id","data__tags","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__title","data__opened_at","data__target_close_date"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"slug","type":"string","max_len":120},{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"title","type":"string","max_len":200},{"name":"public","type":"bool"},{"name":"remote","type":"enum","values":["onsite","hybrid","remote"]},{"name":"status","type":"enum","values":["draft","open","paused","closed","filled"]},{"name":"country","type":"string","max_len":80},{"name":"summary","type":"string","max_len":600},{"name":"benefits","type":"string","max_len":4000},{"name":"currency","type":"string","max_len":8},{"name":"language","type":"string","max_len":16},{"name":"location","type":"string","max_len":120},{"name":"team_ids","type":"list"},{"name":"closed_at","type":"string","max_len":32},{"name":"headcount","type":"number"},{"name":"opened_at","type":"string","max_len":32},{"name":"seniority","type":"enum","values":["junior","mid","senior","lead","principal"]},{"name":"department","type":"string","max_len":120},{"name":"salary_max","type":"number"},{"name":"salary_min","type":"number"},{"name":"description","type":"string","max_len":16000},{"name":"nice_to_have","type":"string","max_len":4000},{"name":"requirements","type":"string","max_len":8000},{"name":"employment_type","type":"enum","values":["full_time","part_time","internship","working_student","freelance","contract"]},{"name":"ai_screen_prompt","type":"string","max_len":4000},{"name":"responsibilities","type":"string","max_len":8000},{"name":"ai_screen_enabled","type":"bool"},{"name":"hiring_manager_id","type":"string","max_len":64},{"name":"salary_visibility","type":"enum","values":["public","team","private"]},{"name":"target_close_date","type":"string","max_len":32},{"name":"external_apply_url","type":"url","max_len":2048},{"name":"knockout_questions","type":"list"},{"name":"screening_questions","type":"list"}]},"message":{"ops":["list","read","create","update","delete"],"create_fields":["candidate_id","application_id","channel","direction","subject","body","status","sent_at","delivered_at","read_at","template_id","from_address","to_address","cc_addresses","thread_id","error"],"update_fields":["subject","body","status","sent_at","delivered_at","read_at","thread_id","error"],"allowed_filters":["data__candidate_id","data__application_id","data__channel","data__status","data__template_id","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__sent_at"],"default_sort":"-data__sent_at","max_limit":200,"fields":[{"name":"body","type":"string","max_len":16000},{"name":"error","type":"string","max_len":600},{"name":"status","type":"enum","values":["draft","queued","sent","delivered","failed","bounced"]},{"name":"channel","type":"enum","values":["email","note"]},{"name":"read_at","type":"string","max_len":32},{"name":"sent_at","type":"string","max_len":32},{"name":"subject","type":"string","max_len":400},{"name":"direction","type":"enum","values":["outbound","inbound"]},{"name":"thread_id","type":"string","max_len":200},{"name":"to_address","type":"string","max_len":320},{"name":"template_id","type":"string","max_len":64,"ref":{"type":"email_template","owned":false,"optional":false}},{"name":"candidate_id","type":"string","max_len":64},{"name":"cc_addresses","type":"list"},{"name":"delivered_at","type":"string","max_len":32},{"name":"from_address","type":"string","max_len":320},{"name":"application_id","type":"string","max_len":64}]},"offer":{"ops":["list","read","create","update","delete"],"create_fields":["application_id","candidate_id","job_id","salary_gross","salary_period","currency","bonus","bonus_note","vacation_days","start_date","expires_at","term","term_until","weekly_hours","remote_policy","status","sent_at","decided_at","letter_body","letter_blob_id","decline_reason"],"update_fields":["salary_gross","salary_period","currency","bonus","bonus_note","vacation_days","start_date","expires_at","term","term_until","weekly_hours","remote_policy","status","sent_at","decided_at","letter_body","letter_blob_id","decline_reason"],"allowed_filters":["data__application_id","data__candidate_id","data__job_id","data__status","data__currency","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__start_date","data__sent_at"],"default_sort":"created_at","max_limit":100,"fields":[{"name":"term","type":"enum","values":["permanent","fixed_term","trial","intern","freelance"]},{"name":"bonus","type":"number"},{"name":"job_id","type":"string","max_len":64},{"name":"status","type":"enum","values":["draft","sent","accepted","declined","withdrawn","expired"]},{"name":"sent_at","type":"string","max_len":32},{"name":"currency","type":"string","max_len":8},{"name":"bonus_note","type":"string","max_len":600},{"name":"decided_at","type":"string","max_len":32},{"name":"expires_at","type":"string","max_len":32},{"name":"start_date","type":"string","max_len":32},{"name":"term_until","type":"string","max_len":32},{"name":"letter_body","type":"string","max_len":16000},{"name":"candidate_id","type":"string","max_len":64},{"name":"salary_gross","type":"number"},{"name":"weekly_hours","type":"number"},{"name":"remote_policy","type":"string","max_len":200},{"name":"salary_period","type":"enum","values":["yearly","monthly","daily","hourly"]},{"name":"vacation_days","type":"number"},{"name":"application_id","type":"string","max_len":64,"ref":{"type":"application","owned":false,"optional":false}},{"name":"decline_reason","type":"string","max_len":600},{"name":"letter_blob_id","type":"string","max_len":64}]},"source":{"ops":["list","read","create","update","delete"],"create_fields":["name","kind","url","active","notes"],"update_fields":["name","kind","url","active","notes"],"allowed_filters":["data__name","data__kind","data__active","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","data__name"],"default_sort":"data__name","max_limit":100,"fields":[{"name":"url","type":"url","max_len":2048},{"name":"kind","type":"enum","values":["linkedin","indeed","stepstone","xing","honeypot","kununu","careers_page","referral","active_sourcing","agency","event","other"]},{"name":"name","type":"string","max_len":200},{"name":"notes","type":"string","max_len":2000},{"name":"active","type":"bool"}]},"task":{"ops":["list","read","create","update","delete"],"create_fields":["title","description","due_date","completed","completed_at","priority","assigned_to","parent_kind","parent_id"],"update_fields":["title","description","due_date","completed","completed_at","priority","assigned_to"],"allowed_filters":["data__parent_id","data__parent_kind","data__assigned_to","data__completed","data__priority","status","is_archived","owned_by","created_by"],"allowed_sorts":["data__due_date","created_at","data__priority"],"default_sort":"data__due_date","max_limit":200,"fields":[{"name":"title","type":"string","max_len":200},{"name":"due_date","type":"string","max_len":32},{"name":"priority","type":"enum","values":["low","normal","high","urgent"]},{"name":"completed","type":"bool"},{"name":"parent_id","type":"string","max_len":64},{"name":"assigned_to","type":"string","max_len":64},{"name":"description","type":"string","max_len":4000},{"name":"parent_kind","type":"enum","values":["candidate","application","job"]},{"name":"completed_at","type":"string","max_len":32}]}} JSON_BLOB RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze MAX_RETRIES = 3 DEFAULT_TIMEOUT = 30 class ApiError < StandardError attr_reader :status, :body_raw def initialize(status, message, body = nil) super("HTTP #{status}: #{message}") @status = status @body_raw = body end end class Client def initialize(token = nil) env_base = ENV['XCLIENT_BASE_URL'] @base_url = (env_base && !env_base.empty? ? env_base : DEFAULT_BASE).sub(%r{/+\z}, '') @token = (token && !token.empty? ? token : ENV.fetch('XCLIENT_TOKEN', '')).to_s @device_id = self.class.send(:load_or_mint_device_id) @session_id = SecureRandom.uuid @meta_sent_once = false @autoupdate_tried = false @meta_lock = Mutex.new end def set_token(token); @token = (token || '').to_s; end def set_base_url(url); @base_url = (url || '').sub(%r{/+\z}, ''); end # ── Identifier persistence ───────────────────────────────────── def self.state_dir home = ENV['HOME'] || ENV['USERPROFILE'] return nil if home.nil? || home.empty? d = File.join(home, ".#ats_client") FileUtils.mkdir_p(d, mode: 0o700) d rescue StandardError nil end private_class_method :state_dir def self.load_or_mint_device_id d = state_dir return SecureRandom.uuid if d.nil? f = File.join(d, 'device.json') if File.exist?(f) begin blob = JSON.parse(File.read(f)) did = blob['device_id'] return did if did.is_a?(String) && did.length >= 32 rescue StandardError # fall through to mint end end fresh = SecureRandom.uuid begin File.write(f, JSON.dump('device_id' => fresh)) File.chmod(0o600, f) rescue StandardError end fresh end private_class_method :load_or_mint_device_id def self.autoupdate_enabled? v = (ENV['XCLIENT_NO_AUTOUPDATE'] || '').downcase !%w[1 true yes].include?(v) end private_class_method :autoupdate_enabled? # ── Editor / runtime fingerprint ─────────────────────────────── def self.fingerprint tp = (ENV['TERM_PROGRAM'] || '').downcase { 'ruby_version' => RUBY_VERSION, 'ruby_engine' => RUBY_ENGINE, 'os' => RUBY_PLATFORM, 'term_program' => ENV['TERM_PROGRAM'], 'editor_env' => ENV['EDITOR'], 'ci' => !!(ENV['CI'] || ENV['GITHUB_ACTIONS']), 'claude_code' => !!(ENV['CLAUDECODE'] || ENV['CLAUDE_CODE_ENTRYPOINT']), 'codex' => !!ENV['CODEX_HOME'], 'vscode' => tp == 'vscode' && ENV['CURSOR_TRACE_ID'].nil?, 'cursor' => !!ENV['CURSOR_TRACE_ID'], 'antigravity' => !!ENV['ANTIGRAVITY_TRACE_ID'], 'jetbrains' => tp.include?('jetbrains'), } end private_class_method :fingerprint # ── HTTP transport ───────────────────────────────────────────── def request_list(path, opts = nil) qs = [] if opts.is_a?(Hash) opts.each do |k, v| next if v.nil? if k.to_s == 'filters' && v.is_a?(Hash) v.each { |fk, fv| next if fv.nil?; qs << "#{URI.encode_www_form_component(fk.to_s)}=#{URI.encode_www_form_component(fv.to_s)}" } next end qs << "#{URI.encode_www_form_component(k.to_s)}=#{URI.encode_www_form_component(v.to_s)}" end end path = path + (path.include?('?') ? '&' : '?') + qs.join('&') unless qs.empty? request_json('GET', path, nil) end def request_json(method, path, body) maybe_autoupdate url = @base_url + path json = body.nil? ? nil : JSON.dump(body) last_err = nil MAX_RETRIES.times do |attempt| begin status, headers, raw_body = send_following_redirects(method.to_s.upcase, url, json) fresh = headers['x-auth-refresh-token'] @token = fresh if fresh && !fresh.empty? if RETRYABLE_STATUSES.include?(status) && attempt + 1 < MAX_RETRIES ra = headers['retry-after'] sleep(self.class.send(:backoff_seconds, attempt, ra ? ra.to_f : nil)) next end parsed = raw_body.empty? ? nil : (JSON.parse(raw_body) rescue nil) if status >= 400 msg = parsed.is_a?(Hash) ? (parsed['detail'] || parsed['message'] || 'request failed') : 'request failed' emit_call_event(method, path, status, false) raise ApiError.new(status, msg, parsed) end emit_call_event(method, path, status, true) return parsed rescue ApiError raise rescue StandardError => e last_err = e sleep(self.class.send(:backoff_seconds, attempt, nil)) if attempt + 1 < MAX_RETRIES next if attempt + 1 < MAX_RETRIES emit_call_event(method, path, 0, false) raise ApiError.new(0, e.message) end end emit_call_event(method, path, 0, false) raise ApiError.new(0, last_err ? last_err.message : 'request failed') end # Walk the redirect chain manually so Authorization can be dropped # on cross-origin hops. Caps at 5 hops; mirrors RFC 7231 method # rewrite semantics. def send_following_redirects(method, url, json) current_url = url current_method = method current_json = json strip_auth = false 6.times do |hop| uri = URI.parse(current_url) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 15, read_timeout: DEFAULT_TIMEOUT) do |http| klass = case current_method when 'GET' then Net::HTTP::Get when 'POST' then Net::HTTP::Post when 'PATCH' then Net::HTTP::Patch when 'PUT' then Net::HTTP::Put when 'DELETE' then Net::HTTP::Delete when 'HEAD' then Net::HTTP::Head else Net::HTTP::Get end req = klass.new(uri.request_uri) req['Accept'] = 'application/json' req['User-Agent'] = user_agent req['X-Client-Channel'] = 'client_' + LANGUAGE req['X-Client-Version'] = CLIENT_VERSION req['X-Analytics-Device-Id'] = @device_id req['X-Analytics-Session-Id'] = @session_id req['Authorization'] = 'Bearer ' + @token if !strip_auth && !@token.empty? if current_json && current_method != 'GET' && current_method != 'HEAD' req['Content-Type'] = 'application/json' req.body = current_json end resp = http.request(req) status = resp.code.to_i headers = {} resp.each_header { |k, v| headers[k.downcase] = v } if status < 300 || status >= 400 || status == 304 || hop == 5 return [status, headers, resp.body.to_s] end loc = headers['location'] return [status, headers, resp.body.to_s] if loc.nil? || loc.empty? next_uri = (URI.parse(loc).absolute? rescue false) ? URI.parse(loc) : uri + loc if origin_of(next_uri) != origin_of(uri) strip_auth = true end if status == 303 || ([301, 302].include?(status) && current_method != 'GET' && current_method != 'HEAD') current_method = 'GET' current_json = nil end current_url = next_uri.to_s end end [0, {}, ''] end private :send_following_redirects def origin_of(uri) port = uri.port || (uri.scheme == 'https' ? 443 : 80) "#{uri.scheme}://#{uri.host}:#{port}".downcase end private :origin_of def self.backoff_seconds(attempt, retry_after) return [retry_after, 60.0].min if retry_after && retry_after >= 0 [2 ** attempt, 60.0].min.to_f end private_class_method :backoff_seconds def user_agent "#ats_client/#{CLIENT_VERSION} (lib/#ruby; ruby/#{RUBY_VERSION})" end private :user_agent # ── Analytics ────────────────────────────────────────────────── def emit_call_event(method, path, status, ok) include_env = nil @meta_lock.synchronize do include_env = !@meta_sent_once @meta_sent_once = true end Thread.new do begin meta = { 'channel' => 'client_' + LANGUAGE, 'client_version' => CLIENT_VERSION, 'module_name' => MODULE_NAME, 'language' => LANGUAGE, 'os' => RUBY_PLATFORM, 'ruby_version' => RUBY_VERSION, } meta['env'] = self.class.send(:fingerprint) if include_env body = { 'device_id' => @device_id, 'session_id' => @session_id, 'events' => [{ 'type' => 'client.call', 'ts_client' => Time.now.to_i, 'meta' => { 'method' => method.to_s.upcase, 'path' => path.split('?').first[0, 128], 'status' => status.to_i, 'ok' => !!ok, }, }], 'meta' => meta, } uri = URI.parse(@base_url + '/xapi2/analytics/challenge') Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 2, read_timeout: 4) do |http| req = Net::HTTP::Post.new(uri.request_uri) req['Content-Type'] = 'application/json' req['User-Agent'] = user_agent req.body = JSON.dump(body) http.request(req) end rescue StandardError # fire and forget end end end private :emit_call_event # ── Auto-update ──────────────────────────────────────────────── def maybe_autoupdate return if @autoupdate_tried @autoupdate_tried = true return unless self.class.send(:autoupdate_enabled?) Thread.new { run_autoupdate } end private :maybe_autoupdate def run_autoupdate d = self.class.send(:state_dir) return if d.nil? stamp = File.join(d, 'update_check.json') if File.exist?(stamp) begin blob = JSON.parse(File.read(stamp)) return if (Time.now.to_i - blob['checked_at'].to_i) < 86400 rescue StandardError end end File.write(stamp, JSON.dump('checked_at' => Time.now.to_i)) uri = URI.parse(@base_url + '/xapi2/clients/version') payload = nil Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 2, read_timeout: 4) do |http| resp = http.request(Net::HTTP::Get.new(uri.request_uri)) payload = (JSON.parse(resp.body) rescue nil) end return unless payload.is_a?(Hash) && payload['version'] && payload['version'] != CLIENT_VERSION uri2 = URI.parse(@base_url + '/xapi2/clients/script.' + LANGUAGE) body = nil Net::HTTP.start(uri2.host, uri2.port, use_ssl: uri2.scheme == 'https', open_timeout: 2, read_timeout: 10) do |http| body = http.request(Net::HTTP::Get.new(uri2.request_uri)).body end return unless body.is_a?(String) && self.class.send(:looks_like_valid_client?, body) target = File.expand_path(__FILE__) tmp = "#{target}.tmp.#{SecureRandom.hex(6)}" File.write(tmp, body) File.rename(tmp, target) rescue StandardError # best-effort end private :run_autoupdate def self.looks_like_valid_client?(blob) return false unless blob.is_a?(String) && blob.bytesize >= 2000 %w[MODULE_NAME CLIENT_VERSION APP_SLUG request_json].all? { |m| blob.include?(m) } end private_class_method :looks_like_valid_client? # ── Generated per-type wrapper methods ───────────────────────── # Every model that exposes an op gets one `_` method # below. The runtime above does the heavy lifting; these wrappers # just pin the URL + HTTP verb. # List `application` rows. Pass any allowed filters as keyword args. def application_list(**opts) request_list('/xapi2/data/application', opts) end # Fetch one `application` row by id. def application_get(id) request_json('GET', '/xapi2/data/application/' + id, nil) end # Create a new `application` row. def application_create(data) request_json('POST', '/xapi2/data/application', data) end # Patch a `application` row. def application_update(id, data) request_json('PATCH', '/xapi2/data/application/' + id, data) end # Delete a `application` row. Returns true on success. def application_delete(id) request_json('DELETE', '/xapi2/data/application/' + id, nil) true end # List `application_note` rows. Pass any allowed filters as keyword args. def application_note_list(**opts) request_list('/xapi2/data/application_note', opts) end # Fetch one `application_note` row by id. def application_note_get(id) request_json('GET', '/xapi2/data/application_note/' + id, nil) end # Create a new `application_note` row. def application_note_create(data) request_json('POST', '/xapi2/data/application_note', data) end # Patch a `application_note` row. def application_note_update(id, data) request_json('PATCH', '/xapi2/data/application_note/' + id, data) end # Delete a `application_note` row. Returns true on success. def application_note_delete(id) request_json('DELETE', '/xapi2/data/application_note/' + id, nil) true end # List `candidate` rows. Pass any allowed filters as keyword args. def candidate_list(**opts) request_list('/xapi2/data/candidate', opts) end # Fetch one `candidate` row by id. def candidate_get(id) request_json('GET', '/xapi2/data/candidate/' + id, nil) end # Create a new `candidate` row. def candidate_create(data) request_json('POST', '/xapi2/data/candidate', data) end # Patch a `candidate` row. def candidate_update(id, data) request_json('PATCH', '/xapi2/data/candidate/' + id, data) end # Delete a `candidate` row. Returns true on success. def candidate_delete(id) request_json('DELETE', '/xapi2/data/candidate/' + id, nil) true end # List `email_template` rows. Pass any allowed filters as keyword args. def email_template_list(**opts) request_list('/xapi2/data/email_template', opts) end # Fetch one `email_template` row by id. def email_template_get(id) request_json('GET', '/xapi2/data/email_template/' + id, nil) end # Create a new `email_template` row. def email_template_create(data) request_json('POST', '/xapi2/data/email_template', data) end # Patch a `email_template` row. def email_template_update(id, data) request_json('PATCH', '/xapi2/data/email_template/' + id, data) end # Delete a `email_template` row. Returns true on success. def email_template_delete(id) request_json('DELETE', '/xapi2/data/email_template/' + id, nil) true end # List `evaluation` rows. Pass any allowed filters as keyword args. def evaluation_list(**opts) request_list('/xapi2/data/evaluation', opts) end # Fetch one `evaluation` row by id. def evaluation_get(id) request_json('GET', '/xapi2/data/evaluation/' + id, nil) end # Create a new `evaluation` row. def evaluation_create(data) request_json('POST', '/xapi2/data/evaluation', data) end # Patch a `evaluation` row. def evaluation_update(id, data) request_json('PATCH', '/xapi2/data/evaluation/' + id, data) end # Delete a `evaluation` row. Returns true on success. def evaluation_delete(id) request_json('DELETE', '/xapi2/data/evaluation/' + id, nil) true end # List `interview` rows. Pass any allowed filters as keyword args. def interview_list(**opts) request_list('/xapi2/data/interview', opts) end # Fetch one `interview` row by id. def interview_get(id) request_json('GET', '/xapi2/data/interview/' + id, nil) end # Create a new `interview` row. def interview_create(data) request_json('POST', '/xapi2/data/interview', data) end # Patch a `interview` row. def interview_update(id, data) request_json('PATCH', '/xapi2/data/interview/' + id, data) end # Delete a `interview` row. Returns true on success. def interview_delete(id) request_json('DELETE', '/xapi2/data/interview/' + id, nil) true end # List `job` rows. Pass any allowed filters as keyword args. def job_list(**opts) request_list('/xapi2/data/job', opts) end # Fetch one `job` row by id. def job_get(id) request_json('GET', '/xapi2/data/job/' + id, nil) end # Create a new `job` row. def job_create(data) request_json('POST', '/xapi2/data/job', data) end # Patch a `job` row. def job_update(id, data) request_json('PATCH', '/xapi2/data/job/' + id, data) end # Delete a `job` row. Returns true on success. def job_delete(id) request_json('DELETE', '/xapi2/data/job/' + id, nil) true end # List `message` rows. Pass any allowed filters as keyword args. def message_list(**opts) request_list('/xapi2/data/message', opts) end # Fetch one `message` row by id. def message_get(id) request_json('GET', '/xapi2/data/message/' + id, nil) end # Create a new `message` row. def message_create(data) request_json('POST', '/xapi2/data/message', data) end # Patch a `message` row. def message_update(id, data) request_json('PATCH', '/xapi2/data/message/' + id, data) end # Delete a `message` row. Returns true on success. def message_delete(id) request_json('DELETE', '/xapi2/data/message/' + id, nil) true end # List `offer` rows. Pass any allowed filters as keyword args. def offer_list(**opts) request_list('/xapi2/data/offer', opts) end # Fetch one `offer` row by id. def offer_get(id) request_json('GET', '/xapi2/data/offer/' + id, nil) end # Create a new `offer` row. def offer_create(data) request_json('POST', '/xapi2/data/offer', data) end # Patch a `offer` row. def offer_update(id, data) request_json('PATCH', '/xapi2/data/offer/' + id, data) end # Delete a `offer` row. Returns true on success. def offer_delete(id) request_json('DELETE', '/xapi2/data/offer/' + id, nil) true end # List `source` rows. Pass any allowed filters as keyword args. def source_list(**opts) request_list('/xapi2/data/source', opts) end # Fetch one `source` row by id. def source_get(id) request_json('GET', '/xapi2/data/source/' + id, nil) end # Create a new `source` row. def source_create(data) request_json('POST', '/xapi2/data/source', data) end # Patch a `source` row. def source_update(id, data) request_json('PATCH', '/xapi2/data/source/' + id, data) end # Delete a `source` row. Returns true on success. def source_delete(id) request_json('DELETE', '/xapi2/data/source/' + id, nil) true end # List `task` rows. Pass any allowed filters as keyword args. def task_list(**opts) request_list('/xapi2/data/task', opts) end # Fetch one `task` row by id. def task_get(id) request_json('GET', '/xapi2/data/task/' + id, nil) end # Create a new `task` row. def task_create(data) request_json('POST', '/xapi2/data/task', data) end # Patch a `task` row. def task_update(id, data) request_json('PATCH', '/xapi2/data/task/' + id, data) end # Delete a `task` row. Returns true on success. def task_delete(id) request_json('DELETE', '/xapi2/data/task/' + id, nil) true end end end