Commit dd338bea1c6322d58fa479e10011ab9d8281ef74
Committed by
GitHub
Merge pull request #1 from ike/logon
Logon, Logoff, and GetUserProfile
Showing
15 changed files
with
280 additions
and
113 deletions
Show diff stats
lib/syspro.rb
... | ... | @@ -5,6 +5,10 @@ require "logger" |
5 | 5 | require "openssl" |
6 | 6 | |
7 | 7 | require "syspro/api_resource" |
8 | +require "syspro/get_logon_profile" | |
9 | +require "syspro/get_version" | |
10 | +require "syspro/logoff" | |
11 | +require "syspro/logon" | |
8 | 12 | require "syspro/syspro_client" |
9 | 13 | require "syspro/singleton_api_resource" |
10 | 14 | require "syspro/syspro_object" |
... | ... | @@ -12,10 +16,8 @@ require "syspro/syspro_response" |
12 | 16 | require "syspro/util" |
13 | 17 | require "syspro/version" |
14 | 18 | |
15 | -require "syspro/api_operations/get_version" | |
16 | 19 | require "syspro/api_operations/request" |
17 | 20 | |
18 | - | |
19 | 21 | module Syspro |
20 | 22 | @api_base = "http://syspro.wildlandlabs.com:90/SYSPROWCFService/Rest" |
21 | 23 | ... | ... |
lib/syspro/api_operations/request.rb
1 | 1 | module Syspro |
2 | 2 | module ApiOperations |
3 | 3 | module Request |
4 | - def request(method, url, params = {}, opts = {}) | |
5 | - client = SysproClient.active_client | |
4 | + module ClassMethods | |
5 | + def request(method, url, params = {}, opts = {}) | |
6 | + warn_on_opts_in_params(params) | |
7 | + | |
8 | + opts = Util.normalize_opts(opts) | |
9 | + opts[:client] ||= SysproClient.active_client | |
6 | 10 | |
7 | 11 | headers = opts.clone |
12 | + user_id = headers.delete(:user_id) | |
13 | + client = headers.delete(:client) | |
14 | + # Assume all remaining opts must be headers | |
8 | 15 | |
9 | 16 | resp = client.execute_request( |
10 | 17 | method, url, |
11 | 18 | headers: headers, |
19 | + user_id: user_id, | |
12 | 20 | params: params |
13 | 21 | ) |
14 | 22 | |
15 | 23 | resp |
16 | - end | |
24 | + end | |
17 | 25 | |
18 | - def warn_on_opts_in_params(params) | |
26 | + private | |
27 | + | |
28 | + def warn_on_opts_in_params(params) | |
19 | 29 | Util::OPTS_USER_SPECIFIED.each do |opt| |
20 | 30 | if params.key?(opt) |
21 | 31 | $stderr.puts("WARNING: #{opt} should be in opts instead of params.") |
22 | 32 | end |
23 | 33 | end |
34 | + end | |
35 | + end # ClassMethods | |
36 | + | |
37 | + def self.included(base) | |
38 | + base.extend(ClassMethods) | |
39 | + end | |
40 | + | |
41 | + protected | |
42 | + | |
43 | + def request(method, url, params = {}, opts = {}) | |
44 | + opts = @opts.merge(Util.normalize_opts(opts)) | |
45 | + self.class.request(method, url, params, opts) | |
24 | 46 | end |
25 | 47 | end |
26 | 48 | end | ... | ... |
lib/syspro/api_resource.rb
1 | -require_relative "syspro_client" | |
2 | -require_relative "api_operations/request" | |
1 | +require "syspro/syspro_object" | |
2 | +require "syspro/api_operations/request" | |
3 | 3 | |
4 | 4 | module Syspro |
5 | - class ApiResource < Syspro::SysproClient | |
5 | + class ApiResource < SysproObject | |
6 | + include Syspro::ApiOperations::Request | |
6 | 7 | |
7 | 8 | def self.class_name |
8 | 9 | name.split("::")[-1] |
9 | 10 | end |
10 | 11 | |
11 | 12 | def self.resource_url |
12 | - if self == APIResource | |
13 | + if self == ApiResource | |
13 | 14 | raise NotImplementedError, "APIResource is an abstract class. You should perform actions on its subclasses (Charge, Customer, etc.)" |
14 | 15 | end |
15 | - "/v1/#{CGI.escape(class_name.downcase)}s" | |
16 | + "/#{CGI.escape(class_name.downcase)}" | |
16 | 17 | end |
17 | 18 | |
18 | 19 | def resource_url |
19 | - unless (id = self["id"]) | |
20 | - raise InvalidRequestError.new("Could not determine which URL to request: #{self.class} instance has invalid ID: #{id.inspect}", "id") | |
21 | - end | |
22 | - "#{self.class.resource_url}/#{CGI.escape(id)}" | |
20 | + #unless (id = self["id"]) | |
21 | + #raise InvalidRequestError.new("Could not determine which URL to request: #{self.class} instance has invalid ID: #{id.inspect}", "id") | |
22 | + #end | |
23 | + #"#{self.class.resource_url}/#{CGI.escape(id)}" | |
23 | 24 | end |
24 | 25 | |
25 | 26 | def refresh | ... | ... |
1 | +module Syspro | |
2 | + class GetLogonProfile < ApiResource | |
3 | + def self.get_logon_profile(user_id) | |
4 | + params = { "UserId" => user_id } | |
5 | + resp = self.request(:get, resource_url, params) | |
6 | + parse_response(resp[0]) | |
7 | + end | |
8 | + | |
9 | + def resource_url | |
10 | + "/GetLogonProfile" | |
11 | + end | |
12 | + | |
13 | + def self.parse_response(resp) | |
14 | + doc = resp.data | |
15 | + | |
16 | + UserProfile.new( | |
17 | + doc.xpath("//CompanyName").text, | |
18 | + doc.xpath("//OperatorCode").text, | |
19 | + doc.xpath("//OperatorGroup").text, | |
20 | + doc.xpath("//OperatorEmailAddress").text, | |
21 | + doc.xpath("//OperatorLocation").text, | |
22 | + doc.xpath("//OperatorLanguageCode").text, | |
23 | + doc.xpath("//SystemLanguage").text, | |
24 | + doc.xpath("//AccountingDate").text, | |
25 | + doc.xpath("//CompanyDate").text, | |
26 | + doc.xpath("//DefaultArBranch").text, | |
27 | + doc.xpath("//DefaultApBranch").text, | |
28 | + doc.xpath("//DefaultBank").text, | |
29 | + doc.xpath("//DefaultWarehouse").text, | |
30 | + doc.xpath("//DefaultCustomer").text, | |
31 | + doc.xpath("//SystemSiteId").text, | |
32 | + doc.xpath("//SystemNationalityCode").text, | |
33 | + doc.xpath("//LocalCurrencyCode").text, | |
34 | + doc.xpath("//CurrencyDescription").text, | |
35 | + doc.xpath("//DefaultRequisitionUser").text, | |
36 | + doc.xpath("//XMLToHTMLTransform").text, | |
37 | + doc.xpath("//CssStyle").text, | |
38 | + doc.xpath("//CssSuffix").text, | |
39 | + doc.xpath("//DecimalFormat").text, | |
40 | + doc.xpath("//DateFormat").text, | |
41 | + doc.xpath("//FunctionalRole").text, | |
42 | + doc.xpath("//DatabaseType").text, | |
43 | + doc.xpath("//SysproVersion").text, | |
44 | + doc.xpath("//EnetVersion").text, | |
45 | + doc.xpath("//SysproServerBitWidth").text, | |
46 | + ) | |
47 | + end | |
48 | + private_class_method :parse_response | |
49 | + | |
50 | + UserProfile = Struct.new(:company_name, :operator_code, :operator_group, :operator_email_address, | |
51 | + :operator_location, :operator_language_code, :system_language, :accounting_date, | |
52 | + :company_date, :default_ar_branch, :default_ap_branch, :default_bank, :default_warehouse, | |
53 | + :default_customer, :system_site_id, :system_nationality_code, :local_currency_code, | |
54 | + :currency_description, :default_requisition_user, :xml_to_html_transform, :css_style, | |
55 | + :css_suffix, :decimal_format, :date_format, :functional_role, :database_type, :syspro_version, | |
56 | + :enet_version, :syspro_server_bit_width) | |
57 | + end | |
58 | +end | |
59 | + | ... | ... |
lib/syspro/api_operations/get_version.rb renamed to lib/syspro/get_version.rb
1 | -require_relative "request" | |
2 | - | |
3 | 1 | module Syspro |
4 | - module ApiOperations | |
5 | - class GetVersion | |
6 | - include ApiOperations::Request | |
2 | + class GetVersion < ApiResource | |
3 | + def self.get_version | |
4 | + resp = self.request(:get, resource_url) | |
5 | + VersionObject.new(resp[0].http_body) | |
6 | + end | |
7 | 7 | |
8 | - def resource_url | |
9 | - "/GetVersion" | |
10 | - end | |
8 | + def resource_url | |
9 | + "/GetVersion" | |
11 | 10 | end |
11 | + | |
12 | + VersionObject = Struct.new(:version) | |
12 | 13 | end |
13 | 14 | end |
14 | 15 | ... | ... |
1 | +module Syspro | |
2 | + class Logoff < ApiResource | |
3 | + def self.logoff(user_id) | |
4 | + params = { "UserId" => user_id } | |
5 | + resp = self.request(:get, resource_url, params) | |
6 | + | |
7 | + if resp[0].http_body == "0" | |
8 | + true | |
9 | + else | |
10 | + resp[0].http_body | |
11 | + end | |
12 | + end | |
13 | + | |
14 | + def resource_url | |
15 | + "/Logoff" | |
16 | + end | |
17 | + end | |
18 | +end | ... | ... |
1 | +module Syspro | |
2 | + class Logon < ApiResource | |
3 | + def self.logon(username, password, company_id, company_password = nil) | |
4 | + params = { | |
5 | + "Operator" => username, | |
6 | + "OperatorPassword" => password, | |
7 | + "CompanyId" => company_id, | |
8 | + "CompanyPassword" => company_password | |
9 | + } | |
10 | + resp = self.request(:get, resource_url, params) | |
11 | + UserIdObject.new(resp[0].http_body) | |
12 | + end | |
13 | + | |
14 | + def resource_url | |
15 | + "/Logon" | |
16 | + end | |
17 | + | |
18 | + UserIdObject = Struct.new(:guid) | |
19 | + end | |
20 | +end | |
21 | + | ... | ... |
lib/syspro/singleton_api_resource.rb
... | ... | @@ -6,7 +6,7 @@ module Syspro |
6 | 6 | if self == SingletonAPIResource |
7 | 7 | raise NotImplementedError, "SingletonAPIResource is an abstract class. You should perform actions on its subclasses (Customer, etc.)" |
8 | 8 | end |
9 | - "/v1/#{CGI.escape(class_name.downcase)}" | |
9 | + "/#{CGI.escape(class_name.downcase)}" | |
10 | 10 | end |
11 | 11 | |
12 | 12 | def resource_url | ... | ... |
lib/syspro/syspro_client.rb
... | ... | @@ -2,11 +2,21 @@ module Syspro |
2 | 2 | class SysproClient |
3 | 3 | attr_accessor :conn, :api_base |
4 | 4 | |
5 | + @verify_ssl_warned = false | |
6 | + | |
5 | 7 | def initialize(conn = nil) |
6 | 8 | self.conn = conn || self.class.default_conn |
7 | 9 | @system_profiler = SystemProfiler.new |
8 | 10 | end |
9 | 11 | |
12 | + def logon(username, password, company_id, company_password) | |
13 | + Syspro::Logon.logon(username, password, company_id, company_password) | |
14 | + end | |
15 | + | |
16 | + def get_syspro_version | |
17 | + Syspro::GetVersion.get_version | |
18 | + end | |
19 | + | |
10 | 20 | def self.active_client |
11 | 21 | Thread.current[:syspro_client] || default_client |
12 | 22 | end |
... | ... | @@ -15,11 +25,6 @@ module Syspro |
15 | 25 | Thread.current[:syspro_client_default_client] ||= SysproClient.new(default_conn) |
16 | 26 | end |
17 | 27 | |
18 | - def get_syspro_version | |
19 | - version_getter = Syspro::ApiOperations::GetVersion.new | |
20 | - version_getter.request(:get, version_getter.resource_url) | |
21 | - end | |
22 | - | |
23 | 28 | # A default Faraday connection to be used when one isn't configured. This |
24 | 29 | # object should never be mutated, and instead instantiating your own |
25 | 30 | # connection and wrapping it in a SysproClient object should be preferred. |
... | ... | @@ -53,8 +58,27 @@ module Syspro |
53 | 58 | end |
54 | 59 | end |
55 | 60 | |
56 | - def execute_request(method, path, api_base: nil, headers: {}, params: {}) | |
61 | + # Executes the API call within the given block. Usage looks like: | |
62 | + # | |
63 | + # client = StripeClient.new | |
64 | + # charge, resp = client.request { Charge.create } | |
65 | + # | |
66 | + def request | |
67 | + @last_response = nil | |
68 | + old_stripe_client = Thread.current[:stripe_client] | |
69 | + Thread.current[:stripe_client] = self | |
70 | + | |
71 | + begin | |
72 | + res = yield | |
73 | + [res, @last_response] | |
74 | + ensure | |
75 | + Thread.current[:stripe_client] = old_stripe_client | |
76 | + end | |
77 | + end | |
78 | + | |
79 | + def execute_request(method, path, user_id: nil, api_base: nil, headers: {}, params: {}) | |
57 | 80 | api_base ||= Syspro.api_base |
81 | + user_id ||= "" | |
58 | 82 | |
59 | 83 | params = Util.objects_to_ids(params) |
60 | 84 | url = api_url(path, api_base) |
... | ... | @@ -82,6 +106,7 @@ module Syspro |
82 | 106 | context.body = body |
83 | 107 | context.method = method |
84 | 108 | context.path = path |
109 | + context.user_id = user_id | |
85 | 110 | context.query_params = query_params ? Util.encode_parameters(query_params) : nil |
86 | 111 | |
87 | 112 | http_resp = execute_request_with_rescues(api_base, context) do |
... | ... | @@ -238,6 +263,7 @@ module Syspro |
238 | 263 | attr_accessor :path |
239 | 264 | attr_accessor :query_params |
240 | 265 | attr_accessor :request_id |
266 | + attr_accessor :user_id | |
241 | 267 | |
242 | 268 | # The idea with this method is that we might want to update some of |
243 | 269 | # context information because a response that we've received from the API | ... | ... |
lib/syspro/syspro_object.rb
1 | 1 | module Syspro |
2 | 2 | class SysproObject |
3 | + include Enumerable | |
3 | 4 | |
4 | - # Re-initializes the object based on a hash of values (usually one that's | |
5 | - # come back from an API call). Adds or removes value accessors as necessary | |
6 | - # and updates the state of internal data. | |
7 | - # | |
8 | - # Protected on purpose! Please do not expose. | |
9 | - # | |
10 | - # ==== Options | |
11 | - # | |
12 | - # * +:values:+ Hash used to update accessors and values. | |
13 | - # * +:opts:+ Options for SysproObject like an API key. | |
14 | - # * +:partial:+ Indicates that the re-initialization should not attempt to | |
15 | - # remove accessors. | |
16 | - def initialize_from(values, opts, partial = false) | |
5 | + def initialize(id = nil, opts = {}) | |
17 | 6 | @opts = Util.normalize_opts(opts) |
7 | + end | |
18 | 8 | |
19 | - # the `#send` is here so that we can keep this method private | |
20 | - @original_values = self.class.send(:deep_copy, values) | |
21 | - | |
22 | - removed = partial ? Set.new : Set.new(@values.keys - values.keys) | |
23 | - added = Set.new(values.keys - @values.keys) | |
24 | - | |
25 | - # Wipe old state before setting new. This is useful for e.g. updating a | |
26 | - # customer, where there is no persistent card parameter. Mark those values | |
27 | - # which don't persist as transient | |
28 | - | |
29 | - remove_accessors(removed) | |
30 | - add_accessors(added, values) | |
9 | + # Determines the equality of two Syspro objects. Syspro objects are | |
10 | + # considered to be equal if they have the same set of values and each one | |
11 | + # of those values is the same. | |
12 | + def ==(other) | |
13 | + other.is_a?(SysproObject) && @values == other.instance_variable_get(:@values) | |
14 | + end | |
31 | 15 | |
32 | - removed.each do |k| | |
33 | - @values.delete(k) | |
34 | - @transient_values.add(k) | |
35 | - @unsaved_values.delete(k) | |
36 | - end | |
16 | + def to_s(*_args) | |
17 | + JSON.pretty_generate(to_hash) | |
18 | + end | |
37 | 19 | |
38 | - update_attributes(values, opts, dirty: false) | |
39 | - values.each_key do |k| | |
40 | - @transient_values.delete(k) | |
41 | - @unsaved_values.delete(k) | |
42 | - end | |
20 | + def inspect | |
21 | + id_string = respond_to?(:id) && !id.nil? ? " id=#{id}" : "" | |
22 | + "#<#{self.class}:0x#{object_id.to_s(16)}#{id_string}> JSON: " + JSON.pretty_generate(@values) | |
23 | + end | |
43 | 24 | |
44 | - self | |
25 | + def keys | |
26 | + @values.keys | |
45 | 27 | end |
46 | 28 | |
47 | - def remove_accessors(keys) | |
48 | - # not available in the #instance_eval below | |
49 | - protected_fields = self.class.protected_fields | |
29 | + def values | |
30 | + @values.values | |
31 | + end | |
50 | 32 | |
51 | - metaclass.instance_eval do | |
52 | - keys.each do |k| | |
53 | - next if protected_fields.include?(k) | |
54 | - next if @@permanent_attributes.include?(k) | |
33 | + def to_hash | |
34 | + maybe_to_hash = lambda do |value| | |
35 | + value && value.respond_to?(:to_hash) ? value.to_hash : value | |
36 | + end | |
55 | 37 | |
56 | - # Remove methods for the accessor's reader and writer. | |
57 | - [k, :"#{k}=", :"#{k}?"].each do |method_name| | |
58 | - remove_method(method_name) if method_defined?(method_name) | |
59 | - end | |
60 | - end | |
38 | + @values.each_with_object({}) do |(key, value), acc| | |
39 | + acc[key] = case value | |
40 | + when Array | |
41 | + value.map(&maybe_to_hash) | |
42 | + else | |
43 | + maybe_to_hash.call(value) | |
44 | + end | |
61 | 45 | end |
62 | 46 | end |
63 | 47 | |
64 | - def add_accessors(keys, values) | |
65 | - # not available in the #instance_eval below | |
66 | - protected_fields = self.class.protected_fields | |
67 | - | |
68 | - metaclass.instance_eval do | |
69 | - keys.each do |k| | |
70 | - next if protected_fields.include?(k) | |
71 | - next if @@permanent_attributes.include?(k) | |
72 | - | |
73 | - if k == :method | |
74 | - # Object#method is a built-in Ruby method that accepts a symbol | |
75 | - # and returns the corresponding Method object. Because the API may | |
76 | - # also use `method` as a field name, we check the arity of *args | |
77 | - # to decide whether to act as a getter or call the parent method. | |
78 | - define_method(k) { |*args| args.empty? ? @values[k] : super(*args) } | |
79 | - else | |
80 | - define_method(k) { @values[k] } | |
81 | - end | |
82 | - | |
83 | - define_method(:"#{k}=") do |v| | |
84 | - if v == "" | |
85 | - raise ArgumentError, "You cannot set #{k} to an empty string. " \ | |
86 | - "We interpret empty strings as nil in requests. " \ | |
87 | - "You may set (object).#{k} = nil to delete the property." | |
88 | - end | |
89 | - @values[k] = Util.convert_to_stripe_object(v, @opts) | |
90 | - dirty_value!(@values[k]) | |
91 | - @unsaved_values.add(k) | |
92 | - end | |
48 | + def each(&blk) | |
49 | + @values.each(&blk) | |
50 | + end | |
93 | 51 | |
94 | - if [FalseClass, TrueClass].include?(values[k].class) | |
95 | - define_method(:"#{k}?") { @values[k] } | |
96 | - end | |
52 | + private | |
53 | + | |
54 | + # Produces a deep copy of the given object including support for arrays, | |
55 | + # hashes, and SysproObject. | |
56 | + def self.deep_copy(obj) | |
57 | + case obj | |
58 | + when Array | |
59 | + obj.map { |e| deep_copy(e) } | |
60 | + when Hash | |
61 | + obj.each_with_object({}) do |(k, v), copy| | |
62 | + copy[k] = deep_copy(v) | |
63 | + copy | |
97 | 64 | end |
65 | + when SysproObject | |
66 | + obj.class.construct_from( | |
67 | + deep_copy(obj.instance_variable_get(:@values)), | |
68 | + obj.instance_variable_get(:@opts).select do |k, _v| | |
69 | + Util::OPTS_COPYABLE.include?(k) | |
70 | + end | |
71 | + ) | |
72 | + else | |
73 | + obj | |
98 | 74 | end |
99 | 75 | end |
76 | + private_class_method :deep_copy | |
77 | + | |
100 | 78 | end |
101 | 79 | end | ... | ... |
lib/syspro/syspro_response.rb
... | ... | @@ -26,8 +26,8 @@ module Syspro |
26 | 26 | # This may throw JSON::ParserError if the response body is not valid JSON. |
27 | 27 | def self.from_faraday_hash(http_resp) |
28 | 28 | resp = SysproResponse.new |
29 | - resp.data = Nokogiri::XML(http_resp[:body]) | |
30 | 29 | resp.http_body = http_resp[:body] |
30 | + resp.data = Nokogiri::XML(resp.http_body) | |
31 | 31 | resp.http_headers = http_resp[:headers] |
32 | 32 | resp.http_status = http_resp[:status] |
33 | 33 | resp.request_id = http_resp[:headers]["Request-Id"] |
... | ... | @@ -39,8 +39,8 @@ module Syspro |
39 | 39 | # This may throw JSON::ParserError if the response body is not valid JSON. |
40 | 40 | def self.from_faraday_response(http_resp) |
41 | 41 | resp = SysproResponse.new |
42 | - resp.data = Nokogiri::XML(http_resp[:body]) | |
43 | 42 | resp.http_body = http_resp.body |
43 | + resp.data = Nokogiri::XML(resp.http_body) | |
44 | 44 | resp.http_headers = http_resp.headers |
45 | 45 | resp.http_status = http_resp.status |
46 | 46 | resp.request_id = http_resp.headers["Request-Id"] | ... | ... |
lib/syspro/util.rb
... | ... | @@ -125,6 +125,13 @@ module Syspro |
125 | 125 | end |
126 | 126 | end |
127 | 127 | |
128 | + def self.url_encode(key) | |
129 | + CGI.escape(key.to_s). | |
130 | + # Don't use strict form encoding by changing the square bracket control | |
131 | + # characters back to their literals. This is fine by the server, and | |
132 | + # makes these parameter strings easier to read. | |
133 | + gsub("%5B", "[").gsub("%5D", "]") | |
134 | + end | |
128 | 135 | end |
129 | 136 | end |
130 | 137 | ... | ... |
test/client_test.rb
... | ... | @@ -3,6 +3,6 @@ require "test_helper" |
3 | 3 | class SysproClientTest < Minitest::Test |
4 | 4 | def test_get_syspro_version |
5 | 5 | client = ::Syspro::SysproClient.new |
6 | - assert_match /(\d+\.)?(\d+\.)?(\d+\.)?(\d+)/, client.get_syspro_version[0].http_body | |
6 | + assert_match (/(\d+\.)?(\d+\.)?(\d+\.)?(\d+)/), client.get_syspro_version.version | |
7 | 7 | end |
8 | 8 | end | ... | ... |
1 | +require "test_helper" | |
2 | + | |
3 | +class LogoffTest < Minitest::Test | |
4 | + def test_successful_logoff | |
5 | + username = "wland" | |
6 | + password = "piperita2016" | |
7 | + company = "L" | |
8 | + company_password = "" | |
9 | + | |
10 | + uid = Syspro::Logon.logon(username, password, company, company_password) | |
11 | + assert_equal true, Syspro::Logoff.logoff(uid.guid) | |
12 | + end | |
13 | + | |
14 | + def test_logoff_error | |
15 | + assert_kind_of String, Syspro::Logoff.logoff('1BB5B3050954BB459A5D034DB5CC386980') | |
16 | + end | |
17 | +end | |
18 | + | ... | ... |
1 | +require "test_helper" | |
2 | + | |
3 | +class LogonTest < Minitest::Test | |
4 | + def test_logon | |
5 | + username = "wland" | |
6 | + password = "piperita2016" | |
7 | + company = "L" | |
8 | + company_password = "" | |
9 | + client = ::Syspro::SysproClient.new | |
10 | + | |
11 | + assert_match (/([A-Z0-9]{33})\w/), client.logon(username, password, company, company_password).guid | |
12 | + end | |
13 | +end | |
14 | + | ... | ... |