From f5d465a0ddaf2508304a05e29cf6a0897c60eb51 Mon Sep 17 00:00:00 2001 From: Alexander Olofsson <alexander.olofsson@liu.se> Date: Tue, 18 Oct 2022 10:52:56 +0200 Subject: [PATCH] Improve documentation and CI --- .gitlab-ci.yml | 8 +++--- .rubocop.yml | 6 ++-- README.md | 30 +++++++++---------- lib/passwordstate/errors.rb | 3 +- lib/passwordstate/resource.rb | 38 +++++++++++++++++++++---- lib/passwordstate/resources/document.rb | 3 +- lib/passwordstate/util.rb | 18 ++++++++++++ 7 files changed, 77 insertions(+), 29 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c217267..1f38042 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,7 +15,7 @@ rubocop: - bundle exec rubocop pages: - stage: deploy + before_script: [] script: - gem install yard - yard doc -o public/ @@ -25,6 +25,6 @@ pages: only: - master -# rspec: -# script: -# - rspec spec +rake: + script: + - bundle exec rake diff --git a/.rubocop.yml b/.rubocop.yml index aea3ae7..dec223c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -35,12 +35,12 @@ Metrics/CyclomaticComplexity: Style/RescueModifier: Enabled: false +Layout/LineLength: + Max: 140 + Metrics/MethodLength: Max: 40 -Metrics/LineLength: - Max: 190 - Metrics/AbcSize: Enabled: false diff --git a/README.md b/README.md index c981a53..b45f479 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A ruby gem for communicating with a Passwordstate instance -The documentation for the development version can be found at http://iti.gitlab-pages.liu.se/ruby-passwordstate +The documentation for the development version can be found at https://iti.gitlab-pages.liu.se/ruby-passwordstate ## Installation @@ -22,20 +22,20 @@ Or install it yourself as: ## Usage example -```irb -irb(main):001:0> require 'passwordstate' -irb(main):002:0> client = Passwordstate::Client.new 'https://passwordstate', username: 'user', password: 'password' -irb(main):003:0> # Passwordstate::Client.new 'https://passwordstate', apikey: 'key' -irb(main):004:0> client.folders -=> [#<Passwordstate::Resources::Folder:0x000055ed493636e8 @folder_name="Example", @folder_id=2, @tree_path="\\Example">, #<Passwordstate::Resources::Folder:0x000055ed49361fa0 @folder_name="Folder", @folder_id=3, @tree_path="\\Example\\Folder">] -irb(main):005:0> client.password_lists.get(7).passwords -=> [#<Passwordstate::Resources::Password:0x0000555fda8acdb8 @title="Webserver1", @user_name="test_web_account", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=2>, #<Passwordstate::Resources::Password:0x0000555fda868640 @title="Webserver2", @user_name="test_web_account2", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=3>, #<Passwordstate::Resources::Password:0x0000555fda84da48 @title="Webserver3", @user_name="test_web_account3", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=4>] -irb(main):006:0> pw = client.password_lists.first.passwords.create title: 'example', user_name: 'someone', generate_password: true -=> #<Passwordstate::Resources::Password:0x0000555fdaf9ce98 @title="example", @user_name="someone", @account_type_id=0, @password="[ REDACTED ]", @allow_export=true, @password_id=12, @generate_password=true, @password_list_id=6> -irb(main):007:0> pw.password -=> "millionfE2rMrcb2LngBTHnDyxdpsGSmK3" -irb(main):008:0> pw.delete -=> true +```ruby +require 'passwordstate' +client = Passwordstate::Client.new 'https://passwordstate', username: 'user', password: 'password' +# Passwordstate::Client.new 'https://passwordstate', apikey: 'key' +client.folders +# [#<Passwordstate::Resources::Folder:0x000055ed493636e8 @folder_name="Example", @folder_id=2, @tree_path="\\Example">, #<Passwordstate::Resources::Folder:0x000055ed49361fa0 @folder_name="Folder", @folder_id=3, @tree_path="\\Example\\Folder">] +client.password_lists.get(7).passwords +# [#<Passwordstate::Resources::Password:0x0000555fda8acdb8 @title="Webserver1", @user_name="test_web_account", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=2>, #<Passwordstate::Resources::Password:0x0000555fda868640 @title="Webserver2", @user_name="test_web_account2", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=3>, #<Passwordstate::Resources::Password:0x0000555fda84da48 @title="Webserver3", @user_name="test_web_account3", @account_type_id=0, @password="[ REDACTED ]", @allow_export=false, @password_id=4>] +pw = client.password_lists.first.passwords.create title: 'example', user_name: 'someone', generate_password: true +# #<Passwordstate::Resources::Password:0x0000555fdaf9ce98 @title="example", @user_name="someone", @account_type_id=0, @password="[ REDACTED ]", @allow_export=true, @password_id=12, @generate_password=true, @password_list_id=6> +pw.password +# "millionfE2rMrcb2LngBTHnDyxdpsGSmK3" +pw.delete +# true ``` ## Contributing diff --git a/lib/passwordstate/errors.rb b/lib/passwordstate/errors.rb index b8719f4..2f4fd02 100644 --- a/lib/passwordstate/errors.rb +++ b/lib/passwordstate/errors.rb @@ -14,7 +14,8 @@ module Passwordstate @response = response @errors = errors - super "Passwordstate responded with an error to the request:\n#{errors.map { |err| err['message'] || err['phrase'] || err['error'] }.join('; ')}" + errorstr = errors.map { |err| err['message'] || err['phrase'] || err['error'] }.join('; ') + super "Passwordstate responded with an error to the request:\n#{errorstr}" end def self.new_by_code(code, req, res, errors = []) diff --git a/lib/passwordstate/resource.rb b/lib/passwordstate/resource.rb index 939a9a4..4601c2b 100644 --- a/lib/passwordstate/resource.rb +++ b/lib/passwordstate/resource.rb @@ -1,24 +1,33 @@ # frozen_string_literal: true module Passwordstate - # A simple resource DSL + # A simple resource DSL helper + # # rubocop:disable Metrics/ClassLength This DSL class will be large class Resource attr_reader :client + # Update the object based off of up-to-date upstream data + # @return [Resource] self def get(**query) set! self.class.get(client, send(self.class.index_field), **query) end + # Push any unapplied changes to the object + # @return [Resource] self def put(body = {}, **query) to_send = modified.merge(self.class.index_field => send(self.class.index_field)) set! self.class.put(client, to_send.merge(body), **query).first end + # Create the object based off of provided information + # @return [Resource] self def post(body = {}, **query) set! self.class.post(client, attributes.merge(body), **query) end + # Delete the object + # @return [Resource] self def delete(**query) self.class.delete(client, send(self.class.index_field), **query) end @@ -29,14 +38,17 @@ module Passwordstate old end + # Is the object stored on the Passwordstate server def stored? !send(self.class.index_field).nil? end + # Check if the resource type is available on the connected Passwordstate server def self.available?(_client) true end + # Retrieve all accessible instances of the resource type, requires the method :get def self.all(client, **query) raise NotAcceptableError, "Read is not implemented for #{self}" unless acceptable_methods.include? :get @@ -49,6 +61,7 @@ module Passwordstate end end + # Retrieve a specific instance of the resource type, requires the method :get def self.get(client, object, **query) raise NotAcceptableError, "Read is not implemented for #{self}" unless acceptable_methods.include? :get @@ -68,6 +81,7 @@ module Passwordstate resp end + # Create a new instance of the resource type, requires the method :post def self.post(client, data, **query) raise NotAcceptableError, "Create is not implemented for #{self}" unless acceptable_methods.include? :post @@ -79,6 +93,7 @@ module Passwordstate new [client.request(:post, path, body: data, query: query, reason: reason)].flatten.first.merge(_client: client) end + # Push new data to an instance of the resource type, requires the method :put def self.put(client, data, **query) raise NotAcceptableError, "Update is not implemented for #{self}" unless acceptable_methods.include? :put @@ -90,6 +105,7 @@ module Passwordstate client.request :put, path, body: data, query: query, reason: reason end + # Delete an instance of the resource type, requires the method :delete def self.delete(client, object, **query) raise NotAcceptableError, "Delete is not implemented for #{self}" unless acceptable_methods.include? :delete @@ -101,10 +117,11 @@ module Passwordstate client.request :delete, "#{path}/#{object}", query: query, reason: reason end - def api_path - self.class.instance_variable_get :@api_path - end - + # Get a hash of all active attributes on the resource instance + # + # @param ignore_redact [Boolean] Should any normally redacted fields be displayed in clear-text + # @param atify [Boolean] Should the resulting hash be returned with instance variable names ('@'-prefixed) + # @option opts nil_as_string [Boolean] (resource dependent) Should nil values be treated as the empty string def attributes(ignore_redact: true, atify: false, **opts) nil_as_string = opts.fetch(:nil_as_string, self.class.nil_as_string) (self.class.send(:accessor_field_names) + self.class.send(:read_field_names) + self.class.send(:write_field_names)).to_h do |field| @@ -136,6 +153,10 @@ module Passwordstate protected + def api_path + self.class.instance_variable_get :@api_path + end + def modified attribs = attributes attribs.reject { |field| old[field] == attribs[field] } @@ -175,21 +196,25 @@ module Passwordstate class << self alias search all + # Get/Set the API path for the resource type def api_path(path = nil) @api_path = path unless path.nil? @api_path end + # Get/Set the index field for the resource type def index_field(field = nil) @index_field = field unless field.nil? @index_field end + # Get/Set whether the resource type requires nil values to be handled as the empty string def nil_as_string(opt = nil) @nil_as_string = opt unless opt.nil? @nil_as_string end + # Get/Set acceptable CRUD methods for the resource type def acceptable_methods(*meths) if meths.empty? @acceptable_methods || %i[post get put delete] @@ -205,15 +230,18 @@ module Passwordstate end end + # Convert a hash from Ruby syntax to Passwordstate syntax def passwordstateify_hash(hash) hash.transform_keys { |k| ruby_to_passwordstate_field(k) } end + # Convert a Passwordstate field name into Ruby syntax def passwordstate_to_ruby_field(field) opts = send(:field_options).find { |(_k, v)| v[:name] == field } opts&.first || field.to_s.snake_case.to_sym end + # Convert a Ruby field name into Passwordstate syntax def ruby_to_passwordstate_field(field) send(:field_options)[field]&.[](:name) || field.to_s.camel_case end diff --git a/lib/passwordstate/resources/document.rb b/lib/passwordstate/resources/document.rb index 6287a63..26decca 100644 --- a/lib/passwordstate/resources/document.rb +++ b/lib/passwordstate/resources/document.rb @@ -40,7 +40,8 @@ module Passwordstate private def validate_store!(store) - raise ArgumentError, 'Store must be one of password, passwordlist, folder' unless %i[password passwordlist folder].include?(store.to_s.downcase.to_sym) + raise ArgumentError, 'Store must be one of password, passwordlist, folder' \ + unless %i[password passwordlist folder].include?(store.to_s.downcase.to_sym) store.to_s.downcase.to_sym end diff --git a/lib/passwordstate/util.rb b/lib/passwordstate/util.rb index 649a257..8bc5d01 100644 --- a/lib/passwordstate/util.rb +++ b/lib/passwordstate/util.rb @@ -4,6 +4,11 @@ require 'net/http' require 'net/ntlm' module Net + # NTLM header extension + # + # @example Setting NTLM auth + # req = Net::HTTP::Get.new URI('https://example.com') + # req.ntlm_auth 'username', 'password' module HTTPHeader attr_reader :ntlm_auth_information, :ntlm_auth_options @@ -22,10 +27,12 @@ module Net end class String + # Convert a snake_case string to CamelCase def camel_case split('_').collect(&:capitalize).join end + # Convert a CamelCase string to snake_case def snake_case gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') .gsub(/([a-z\d])([A-Z])/, '\1_\2') @@ -33,6 +40,7 @@ class String .downcase end + # Find a specific line in a block of text def find_line(&_block) raise ArgumentError, 'No block given' unless block_given? @@ -43,6 +51,16 @@ class String end module Passwordstate + # Extensions on Net::HTTP to allow NTLM digest auth + # + # @example Using NTLM auth + # uri = URI('https://example.com/some_object') + # Net::HTTP.start uri.host, uri.port { |http| + # req = Net::HTTP::Get.new uri + # req.ntlm_auth 'username', 'password' + # + # http.request req + # } module NetHTTPExtensions def request(req, body = nil, &block) return super(req, body, &block) if req.ntlm_auth_information.nil? -- GitLab