From 26053b7bbe434af65fa5950b17771e40ecc01c98 Mon Sep 17 00:00:00 2001 From: Mariani Lucas Date: Sat, 22 Aug 2020 21:54:24 -0300 Subject: [PATCH] First release --- .dockerignore | 6 + .gitignore | 23 ++ .rubocop.yml | 44 +++ .rubocop_todo.yml | 44 +++ CHANGELOG.md | 9 + CONTRIBUTING.md | 195 ++++++++++++++ Dockerfile | 8 + Gemfile | 10 + LICENSE.txt | 24 ++ README.md | 250 ++++++++++++++++++ Rakefile | 15 ++ docker-compose.yml | 40 +++ docker.env | 2 + lib/sonarqube.rb | 46 ++++ lib/sonarqube/api.rb | 22 ++ lib/sonarqube/client.rb | 36 +++ lib/sonarqube/client/groups.rb | 124 +++++++++ lib/sonarqube/client/projects.rb | 111 ++++++++ lib/sonarqube/client/users.rb | 125 +++++++++ lib/sonarqube/configuration.rb | 55 ++++ lib/sonarqube/error.rb | 154 +++++++++++ lib/sonarqube/objectified_hash.rb | 51 ++++ lib/sonarqube/request.rb | 92 +++++++ lib/sonarqube/version.rb | 5 + sonarqube.gemspec | 29 ++ spec/fixtures/group.json | 1 + spec/fixtures/group_create.json | 1 + .../group_create_with_description.json | 1 + spec/fixtures/group_delete.json | 1 + spec/fixtures/group_update.json | 1 + spec/fixtures/groups.json | 1 + spec/fixtures/member_add.json | 1 + spec/fixtures/member_remove.json | 1 + spec/fixtures/members_list.json | 1 + spec/fixtures/project.json | 1 + spec/fixtures/project_delete.json | 1 + spec/fixtures/project_update_key.json | 1 + spec/fixtures/project_update_visibility.json | 1 + spec/fixtures/projects_bulk_delete.json | 1 + spec/fixtures/projects_search.json | 1 + spec/fixtures/user_change_password.json | 1 + spec/fixtures/user_create.json | 1 + spec/fixtures/user_deactivate.json | 1 + spec/fixtures/user_groups.json | 1 + spec/fixtures/user_update.json | 1 + spec/fixtures/user_update_login.json | 1 + spec/fixtures/users.json | 1 + spec/sonarqube/api_spec.rb | 13 + spec/sonarqube/client/groups_spec.rb | 139 ++++++++++ spec/sonarqube/client/projects_spec.rb | 101 +++++++ spec/sonarqube/client/users_spec.rb | 114 ++++++++ spec/sonarqube/error_spec.rb | 106 ++++++++ spec/sonarqube/objectified_hash_spec.rb | 76 ++++++ spec/sonarqube/request_spec.rb | 99 +++++++ spec/sonarqube_spec.rb | 91 +++++++ spec/spec_helper.rb | 41 +++ 56 files changed, 2322 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .rubocop_todo.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 docker-compose.yml create mode 100644 docker.env create mode 100644 lib/sonarqube.rb create mode 100644 lib/sonarqube/api.rb create mode 100644 lib/sonarqube/client.rb create mode 100644 lib/sonarqube/client/groups.rb create mode 100644 lib/sonarqube/client/projects.rb create mode 100644 lib/sonarqube/client/users.rb create mode 100644 lib/sonarqube/configuration.rb create mode 100644 lib/sonarqube/error.rb create mode 100644 lib/sonarqube/objectified_hash.rb create mode 100644 lib/sonarqube/request.rb create mode 100644 lib/sonarqube/version.rb create mode 100644 sonarqube.gemspec create mode 100644 spec/fixtures/group.json create mode 100644 spec/fixtures/group_create.json create mode 100644 spec/fixtures/group_create_with_description.json create mode 100644 spec/fixtures/group_delete.json create mode 100644 spec/fixtures/group_update.json create mode 100644 spec/fixtures/groups.json create mode 100644 spec/fixtures/member_add.json create mode 100644 spec/fixtures/member_remove.json create mode 100644 spec/fixtures/members_list.json create mode 100644 spec/fixtures/project.json create mode 100644 spec/fixtures/project_delete.json create mode 100644 spec/fixtures/project_update_key.json create mode 100644 spec/fixtures/project_update_visibility.json create mode 100644 spec/fixtures/projects_bulk_delete.json create mode 100644 spec/fixtures/projects_search.json create mode 100644 spec/fixtures/user_change_password.json create mode 100644 spec/fixtures/user_create.json create mode 100644 spec/fixtures/user_deactivate.json create mode 100644 spec/fixtures/user_groups.json create mode 100644 spec/fixtures/user_update.json create mode 100644 spec/fixtures/user_update_login.json create mode 100644 spec/fixtures/users.json create mode 100644 spec/sonarqube/api_spec.rb create mode 100644 spec/sonarqube/client/groups_spec.rb create mode 100644 spec/sonarqube/client/projects_spec.rb create mode 100644 spec/sonarqube/client/users_spec.rb create mode 100644 spec/sonarqube/error_spec.rb create mode 100644 spec/sonarqube/objectified_hash_spec.rb create mode 100644 spec/sonarqube/request_spec.rb create mode 100644 spec/sonarqube_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d33d26 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +Gemfile.lock +Dockerfile +docker-compose.yml +*.gem +deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e2847c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +*.gem +*.rbc +*.swp +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp +vendor/bundle +.idea +.ruby-version +.ruby-gemset +test.rb \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f25cf2a --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,44 @@ +--- +inherit_from: .rubocop_todo.yml + +require: rubocop-performance + +AllCops: + TargetRubyVersion: 2.5 + NewCops: enable + +Layout/LineLength: + Max: 123 + Exclude: + - 'lib/sonarqube/client/*' + - 'spec/**/*' + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + +Style/Documentation: + Enabled: false + +Style/ClassAndModuleChildren: + Exclude: + - 'lib/sonarqube/*' + - 'lib/sonarqube/client/*' + +Lint/NonDeterministicRequireOrder: + Enabled: false + +Style/HashEachMethods: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Style/OptionalBooleanParameter: + Enabled: false + +Lint/MissingSuper: + Enabled: false \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..631ae5c --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,44 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2020-06-28 04:00:53 UTC using RuboCop version 0.86.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: 4 +# Configuration parameters: IgnoredMethods. +Metrics/AbcSize: + Max: 34 + +# Offense count: 3 +# Configuration parameters: IgnoredMethods. +Metrics/CyclomaticComplexity: + Max: 13 + +# Offense count: 8 +# Configuration parameters: CountComments, ExcludedMethods. +Metrics/MethodLength: + Max: 34 + +# Offense count: 2 +# Configuration parameters: CountComments. +Metrics/ModuleLength: + Max: 156 + +# Offense count: 2 +# Configuration parameters: CountKeywordArgs. +Metrics/ParameterLists: + Max: 6 + +# Offense count: 1 +# Configuration parameters: IgnoredMethods. +Metrics/PerceivedComplexity: + Max: 10 + +# Offense count: 1 +# Cop supports --auto-correct. +# Configuration parameters: AutoCorrect. +Security/JSONLoad: + Exclude: + - 'lib/sonarqube/request.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..63ba504 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +## CHANGELOG + +### Newer releases + +Please see: https://github.com/psyreactor/sonarqube-ruby/releases + + +### 1.0.0 (19/08/2020) +- \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4ecae1f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,195 @@ +# Contributing to Sonarqube + +Please take a moment to review this document in order to make the contribution +process easy and effective for everyone involved! + +## Using the issue tracker + +You can use the issues tracker for: + +* [bug reports](#bug-reports) +* [feature requests](#feature-requests) +* [submitting pull requests](#pull-requests) + +Use [Stackoverflow](http://stackoverflow.com/) for questions and personal support requests. + +## Bug reports + +A bug is a _demonstrable problem_ that is caused by the code in the repository. +Good bug reports are extremely helpful - thank you! + +Guidelines for bug reports: + +1. **Use the GitHub issue search** — check if the issue has already been + reported. + +2. **Check if the issue has been fixed** — try to reproduce it using the + `master` branch in the repository. + +3. **Isolate and report the problem** — ideally create a reduced test + case. + +Please try to be as detailed as possible in your report. Include information about +your Ruby, Sonarqube client and Sonarqube instance versions. Please provide steps to +reproduce the issue as well as the outcome you were expecting! All these details +will help developers to fix any potential bugs. + +Example: + +> Short and descriptive example bug report title +> +> A summary of the issue and the environment in which it occurs. If suitable, +> include the steps required to reproduce the bug. +> +> 1. This is the first step +> 2. This is the second step +> 3. Further steps, etc. +> +> Any other information you want to share that is relevant to the issue being +> reported. This might include the lines of code that you have identified as +> causing the bug, and potential solutions (and your opinions on their +> merits). + +## Feature requests + +Feature requests are welcome. But take a moment to find out whether your idea +fits with the scope and aims of the project. It's up to *you* to make a strong +case to convince the community of the merits of this feature. +Please provide as much detail and context as possible. + +## Contributing Documentation + +Code documentation has a special convention: it uses [YARD](http://yardoc.org/) +formatting and the first paragraph is considered to be a short summary. + +For methods say what it will do. For example write something like: + +```ruby +# Reverses the contents of a String or IO object. +# +# @param [String, #read] contents the contents to reverse +# @return [String] the contents reversed lexically +def reverse(contents) + contents = contents.read if contents.respond_to? :read + contents.reverse +end +``` + +For classes, modules say what it is. For example write something like: + +```ruby +# Defines methods related to groups. +module Groups +``` + +Keep in mind that the documentation notes might show up in a summary somewhere, +long texts in the documentation notes create very ugly summaries. As a rule of thumb +anything longer than 80 characters is too long. + +Try to keep unnecessary details out of the first paragraph, it's only there to +give a user a quick idea of what the documented "thing" does/is. The rest of the +documentation notes can contain the details, for example parameters and what +is returned. + +If possible include examples. For example: + +```ruby +# Gets information about a project. +# +# @example +# Sonarqube.project(3) +# Sonarqube.project('sonarqube') +# +# @param [Integer, String] id The ID or name of a project. +# @return [Sonarqube::ObjectifiedHash] +def project(id) +``` + +This makes it easy to test the examples so that they don't go stale and examples +are often a great help in explaining what a method does. + +## Pull requests + +Good pull requests - patches, improvements, new features - are a fantastic +help. They should remain focused in scope and avoid containing unrelated +commits. + +**IMPORTANT**: By submitting a patch, you agree that your work will be +licensed under the license used by the project. + +If you have any large pull request in mind (e.g. implementing features, +refactoring code, etc), **please ask first** otherwise you risk spending +a lot of time working on something that the project's developers might +not want to merge into the project. + +Please adhere to the coding conventions in the project (indentation, +accurate comments, etc.) and don't forget to add your own tests and +documentation. When working with git, we recommend the following process +in order to craft an excellent pull request: + +1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork, + and configure the remotes: + + ```sh + # Clone your fork of the repo into the current directory + git clone https://github.com//sonarqube + # Navigate to the newly cloned directory + cd sonarqube + # Assign the original repo to a remote called "upstream" + git remote add upstream https://github.com/psyreactor/sonarqube + ``` + +2. If you cloned a while ago, get the latest changes from upstream: + + ```bash + git checkout master + git pull upstream master + ``` + +3. Create a new topic branch (off of `master`) to contain your feature, change, + or fix. + + **IMPORTANT**: Making changes in `master` is discouraged. You should always + keep your local `master` in sync with upstream `master` and make your + changes in topic branches. + + ```sh + git checkout -b + ``` + +4. Commit your changes in logical chunks. Keep your commit messages organized, + with a short description in the first line and more detailed information on + the following lines. Feel free to use Git's + [interactive rebase](https://help.github.com/articles/about-git-rebase/) + feature to tidy up your commits before making them public. + +5. Make sure all the tests are still passing. + + ```sh + rake + ``` + +6. Push your topic branch up to your fork: + + ```sh + git push origin + ``` + +7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) + with a clear title and description. + +8. If you haven't updated your pull request for a while, you should consider + rebasing on master and resolving any conflicts. + + **IMPORTANT**: _Never ever_ merge upstream `master` into your branches. You + should always `git rebase` on `master` to bring your changes up to date when + necessary. + + ```sh + git checkout master + git pull upstream master + git checkout + git rebase master + ``` + +Thank you for your contributions! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22f9f8c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM ruby:2.7 + +WORKDIR /app + +COPY . ./ +RUN bundle install + +CMD ["irb"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..cc2712c --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in sonarqube.gemspec +gemspec + +gem 'pry' +gem 'rubocop' +gem 'rubocop-performance' diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f9d7743 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,24 @@ +Copyright (c) 2020 Lucas Mariani +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..08e9be1 --- /dev/null +++ b/README.md @@ -0,0 +1,250 @@ +# Sonarqube + +[![Build Status](https://img.shields.io/github/workflow/status/psyreactor/sonarqube/CI/master)](https://github.com/psyreactor/sonarqube/actions?query=workflow%3ARuby) +[![Inline docs](https://inch-ci.org/github/psyreactor/sonarqube.svg)](https://inch-ci.org/github/psyreactor/sonarqube) +[![Gem version](https://img.shields.io/gem/v/sonarqube.svg)](https://rubygems.org/gems/sonarqube) +[![License](https://img.shields.io/badge/license-BSD-red.svg)](https://github.com/psyreactor/sonarqube/blob/master/LICENSE.txt) + +[website](https://psyreactor.github.io/sonarqube) | +[documentation](https://www.rubydoc.info/gems/sonarqube/frames) + +Sonarqube is a Ruby wrapper and CLI for the Sonarqube API +As of version `1.0.0` this gem only supports Sonarqube 7.9. + +## Installation + +Install it from rubygems: + +```sh +gem install sonarqube +``` + +Or add to a Gemfile: + +```ruby +gem 'sonarqube' +# gem 'sonarqube', github: 'psyreactor/sonarqube' +``` + +Mac OS users can install using Homebrew (may not be the latest version): + +```sh +brew install sonarqube-gem +``` + +## Usage + +### Configuration example + +```ruby +Sonarqube.configure do |config| + config.endpoint = 'https://example.net:9000' # API endpoint URL, default: ENV['SONARQUBE_API_ENDPOINT'] + config.private_token = 'Dfrt938dSgAOWd4' # user's private token, default: ENV['SONARQUBE_API_PRIVATE_TOKEN'] + # Optional + # config.user_agent = 'Custom User Agent' # user agent, default: 'Sonarqube Ruby Gem [version]' +end +``` + +### Usage examples + +```ruby +# set an API endpoint +Sonarqube.endpoint = 'https://example.net:9000' +# => "https://example.net:9000" + +# set a user private token +Sonarqube.private_token = 'Dfrt938dSgAOWd4' +# => "Dfrt938dSgAOWd4" + +# configure a proxy server +Sonarqube.http_proxy('proxyhost', 8888) +# proxy server with basic auth +Sonarqube.http_proxy('proxyhost', 8888, 'proxyuser', 'strongpasswordhere') +# set timeout for responses +ENV['SONARQUBE_API_HTTPARTY_OPTIONS'] = '{read_timeout: 60}' +``` + +### initialize a new client with custom headers +```ruby +g = Sonarqube.client( + endpoint: 'https://example.com:9000', + private_token: 'Dfrt938dSgAOWd4', + httparty: { + headers: { 'Cookie' => 'sonarqube_canary=true' } + } +) +# => # +``` + +### Projects + +#### Create Project +```ruby +project = Sonarqube.project_create('new_project') +# => {"key"=>"new_project", "name"=>"new_project", "qualifier"=>"TRK", "visibility"=>"public"}}} +project.project.key +# => "new_project" +project.to_hash +# => {"project"=>{"key"=>"new_project", "name"=>"new_project", "qualifier"=>"TRK", "visibility"=>"public"}} +``` + +#### Delete Project +```ruby +project = Sonarqube.project_delete('test') +# => # true +``` + +#### Search Project +```ruby +projects = Sonarqube.project_search() +# => #{"pageIndex"=>1, "pageSize"=>100, "total"=>2}, "components"=>[{"organization"=>"default-organization", "key"=>"old_key", "name"=>"new_proyect", "qualifier"=>"TRK", "visibility"=>"private"}, {"organization"=>"default-organization", "key"=>"test", "name"=>"test", "qualifier"=>"TRK", "visibility"=>"public"}]}} +projects.components.each do | project | + puts "name: #{project.name}" + puts "key: #{project.key}" +end +# name: new_proyect +# key: old_key +# name: test +# key: test +``` + +### Users + +#### Create User +```ruby +user = Sonarqube.user_create('new_user', 'key_new_user' ,'secretpassword') +# => #{"login"=>"login_name", "name"=>"name_user", "scmAccounts"=>[], "active"=>true, "local"=>true}}} +user.user.login +# login_name +user.user.name +# name_user +``` + +#### Delete User +```ruby +user = Sonarqube.user_delete('test') +# => # true +``` + +#### Search User +```ruby +users = Sonarqube.users_search() +# => #{"pageIndex"=>1, "pageSize"=>50, "total"=>5}, "users"=>[{"login"=>"admin", "name"=>"Administrator", "active"=>true, "groups"=>["sonar-administrators", "sonar-users"], "tokensCount"=>1, "local"=>true, "externalIdentity"=>"admin", "externalProvider"=>"sonarqube", "lastConnectionDate"=>"2020-08-22T23:09:14+0000"}, {"login"=>"new_user", "name"=>"key_new_user", "active"=>true, "groups"=>["sonar-users"], "tokensCount"=>0, "local"=>true, "externalIdentity"=>"new_user", "externalProvider"=>"sonarqube"}, {"login"=>"login_name", "name"=>"name_user", "active"=>true, "groups"=>["sonar-users"], "tokensCount"=>0, "local"=>true, "externalIdentity"=>"login_name", "externalProvider"=>"sonarqube"}, {"login"=>"test3", "name"=>"test QA", "active"=>true, "groups"=>["sonar-users"], "tokensCount"=>0, "local"=>true, "externalIdentity"=>"test3", "externalProvider"=>"sonarqube"}, {"login"=>"newlogin", "name"=>"test QA", "active"=>true, "groups"=>["sonar-users"], "tokensCount"=>0, "local"=>true, "externalIdentity"=>"newlogin", "externalProvider"=>"sonarqube"}]}} +users.users.each do | user | + puts "name: #{user.name}" + puts "login: #{user.login}" + puts "lastConection: #{user.lastConnectionDate}" if defined? user.lastConnectionDate +end +# name: Administrator +# login: admin +# lastConection: 2020-08-22T23:09:14+0000 +# name: key_new_user +# login: new_user +# name: name_user +# login: login_name +# name: test QA +# login: test3 +# name: test QA +# login: newlogin +``` + +### Groups + +#### Create Group +```ruby +group = Sonarqube.create_group('New-Group', {description: 'Sonarqube group users'}) +# => #{"uuid"=>"AXQYrrgCsrvdoo0YodNM", "organization"=>"default-organization", "name"=>"New-Group", "description"=>"Sonarqube group users", "membersCount"=>0, "default"=>false}}} +group.group.uuid +# AXQYrrgCsrvdoo0YodNM +group.group.description +# Sonarqube group users +``` + +#### Delete Group +```ruby +group = Sonarqube.group_delete('New-Group') +# => # true +``` + +#### Search Group +```ruby +groups = Sonarqube.search_groups({ q: 'sonar-users' }) +# => #{"pageIndex"=>1, "pageSize"=>100, "total"=>1}, "groups"=>[{"uuid"=>"AXOt93S3gMZPhbn-E_O7", "name"=>"sonar-users", "description"=>"Any new users created will automatically join this group", "membersCount"=>5, "default"=>true}]}} +groups.groups.each do | group | + puts "name: #{group.name}" + puts "login: #{group.description}" + puts "membersCount: #{group.membersCount}" +end +# name: sonar-users +# description: Any new users created will automatically join this group +# MembersCount: 5 +``` + +For more information, refer to [documentation](https://www.rubydoc.info/gems/sonarqube/frames). + +## Development + +### With a dockerized Sonarqube instance + +```shell +docker-compose up -d sonarqube # Will start the Sonarqube instance in the background (approx. 3 minutes) +``` + +After a while, your Sonarqube instance will be accessible on http://localhost:9000. + +You can login with the admin/admin user/password. + +You can now setup a personal access token here: http://localhost:9000 + +Once you have your token, set the variables to the correct values in the `docker.env` file. + +Then, launch the tool: + +```shell +docker-compose run app +``` + +```ruby +Sonarqube.users +=> [#1, "name"=>"Administrator", "username"=>"root", ...] +``` + +To launch the specs: + +```shell +docker-compose run app rake spec +``` + + +### With an external Sonarqube instance + +First, set the variables to the correct values in the `docker.env` file. + +Then, launch the tool: + +```shell +docker-compose run app +``` + +```ruby +Sonarqube.users +=> [#1, "name"=>"Administrator", "username"=>"root", ...] +``` + +To launch the specs, + +```shell +docker-compose run app rake spec +``` + +For more information see [CONTRIBUTING.md](https://github.com/psyreactor/sonarqube/blob/master/CONTRIBUTING.md). + +## License + +Released under the BSD 2-clause license. See LICENSE.txt for details. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..3ffd91e --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.rspec_opts = ['--color', '--format d'] +end + +require 'rubocop/rake_task' +RuboCop::RakeTask.new(:rubocop) do |task| + task.options = ['-D', '--parallel'] +end + +task default: :spec diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a8c77e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +--- +version: '3' +services: + app: + build: . + volumes: + - ./:/app + env_file: docker.env + + sonarqube-postgresql: + image: docker.bintray.io/postgres:9.6.11 + container_name: sonarqube-postgresql + ports: + - 5433:5433 + environment: + POSTGRES_DB: sonar + POSTGRES_USER: sonar + POSTGRES_PASSWORD: P4ssw0rd + command: -p 5433 + volumes: + - sonarqube-postgresql:/var/lib/postgresql/data + + sonarqube: + image: sonarqube + container_name: sonarqube + depends_on: + - sonarqube-postgresql + links: + - sonarqube-postgresql + ports: + - 9000:9000 + environment: + SONARQUBE_JDBC_URL: jdbc:postgresql://localhost:5433/sonar + SONARQUBE_JDBC_USERNAME: sonar + SONARQUBE_JDBC_PASSWORD: P4ssw0rd + volumes: + - sonarqube_conf:/opt/sonarqube/conf + - sonarqube_data:/opt/sonarqube/data + - sonarqube_extensions:/opt/sonarqube/extensions + - sonarqube_bundled-plugins:/opt/sonarqube/lib/bundled-plugins diff --git a/docker.env b/docker.env new file mode 100644 index 0000000..d066d1a --- /dev/null +++ b/docker.env @@ -0,0 +1,2 @@ +SONAR_API_ENDPOINT=http://localhost:9000 +SONAR_API_PRIVATE_TOKEN=g98hwj_dbt_lkq029i diff --git a/lib/sonarqube.rb b/lib/sonarqube.rb new file mode 100644 index 0000000..aece1e7 --- /dev/null +++ b/lib/sonarqube.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'sonarqube/version' +require 'sonarqube/objectified_hash' +require 'sonarqube/configuration' +require 'sonarqube/error' +require 'sonarqube/request' +require 'sonarqube/api' +require 'sonarqube/client' + +module Sonarqube + extend Configuration + + # Alias for Sonarqube::Client.new + # + # @return [Sonarqube::Client] + def self.client(options = {}) + Sonarqube::Client.new(options) + end + + # Delegate to Sonarqube::Client + def self.method_missing(method, *args, &block) + return super unless client.respond_to?(method) + + client.send(method, *args, &block) + end + + # Delegate to Sonarqube::Client + def self.respond_to_missing?(method_name, include_private = false) + client.respond_to?(method_name) || super + end + + # Delegate to HTTParty.http_proxy + def self.http_proxy(address = nil, port = nil, username = nil, password = nil) + Sonarqube::Request.http_proxy(address, port, username, password) + end + + # Returns an unsorted array of available client methods. + # + # @return [Array] + def self.actions + hidden = + /endpoint|private_token|auth_token|user_agent|get|post|put|\Adelete\z|validate\z|request_defaults|httparty/ + (Sonarqube::Client.instance_methods - Object.methods).reject { |e| e[hidden] } + end +end diff --git a/lib/sonarqube/api.rb b/lib/sonarqube/api.rb new file mode 100644 index 0000000..01552c5 --- /dev/null +++ b/lib/sonarqube/api.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Sonarqube + # @private + class API < Request + # @private + attr_accessor(*Configuration::VALID_OPTIONS_KEYS) + # @private + alias auth_token= private_token= + + # Creates a new API. + # @raise [Error:MissingCredentials] + def initialize(options = {}) + options = Sonarqube.options.merge(options) + (Configuration::VALID_OPTIONS_KEYS + [:auth_token]).each do |key| + send("#{key}=", options[key]) if options[key] + end + request_defaults + self.class.headers 'User-Agent' => user_agent + end + end +end diff --git a/lib/sonarqube/client.rb b/lib/sonarqube/client.rb new file mode 100644 index 0000000..06928fa --- /dev/null +++ b/lib/sonarqube/client.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Sonarqube + # Wrapper for the Sonarqube REST API. + class Client < API + Dir[File.expand_path('client/*.rb', __dir__)].each { |f| require f } + + # Please keep in alphabetical order + include Groups + include Projects + include Users + + # Text representation of the client, masking private token. + # + # @return [String] + def inspect + inspected = super + inspected.sub! @private_token, only_show_last_four_chars(@private_token) if @private_token + inspected + end + + # Utility method for URL encoding of a string. + # Copied from https://ruby-doc.org/stdlib-2.7.0/libdoc/erb/rdoc/ERB/Util.html + # + # @return [String] + def url_encode(url) + url.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/n) { |m| sprintf('%%%02X', m.unpack1('C')) } # rubocop:disable Style/FormatString, Style/FormatStringToken + end + + private + + def only_show_last_four_chars(token) + "#{'*' * (token.size - 4)}#{token[-4..-1]}" + end + end +end diff --git a/lib/sonarqube/client/groups.rb b/lib/sonarqube/client/groups.rb new file mode 100644 index 0000000..a8c3ccc --- /dev/null +++ b/lib/sonarqube/client/groups.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class Sonarqube::Client + # Defines methods related to groups. + # @see https://SONAR_URL/web_api/api/user_groups + module Groups + # Search for user groups. + # + # @example + # Sonarqube.groups_search + # Sonarqube.groups_search({ ps: 3, p: 2 }) + # Sonarqube.groups_search({ ps: 3, p: 2 , q: sonar-users}) + # + # @param [Hash] options A customizable set of options. + # @option options [String] :f Comma-separated list of the fields to be returned in response. All the fields are returned by default. + # @option options [Integer] :ps Page size number of projects to return per page + # @option options [Integer] :p The page to retrieve + # @option options [String] :q Limit search to names that contain the supplied string. + # (Any provided options will be passed to Sonarqube. See {https://SONAR_URL/web_api/api/user_groups/search} + # @return [Array] + def search_groups(options = {}) + get('/api/user_groups/search', query: options) + end + alias groups_search search_groups + + # Creates a new group. + # + # @example + # Sonarqube.create_group('new-group') + # Sonarqube.create_group('sonarqube', { description: 'New Sonarqube project' }) + # + # @param [String] name(required) The name of a group. + # @param [Hash] options A customizable set of options. + # @option options [String] :description Description for the new group. A group description cannot be larger than 200 characters. + # @return [Sonarqube::ObjectifiedHash] Information about created group. + def create_group(name, options = {}) + body = { name: name }.merge(options) + post('/api/user_groups/create', body: body) + end + alias group_create create_group + + # Delete's a group. + # + # @example + # Sonarqube.delete_group('projecto') + # + # @param [String] name(required) The name of a group + # @return [Sonarqube::ObjectifiedHash] Empty hash response. + def delete_group(name) + post('/api/user_groups/delete', body: { name: name }) + end + alias group_delete delete_group + + # Update group. + # + # @example + # Sonarqube.group_members('AXQRcKrW9pRiZzanEJ2E') + # Sonarqube.group_members('AXQRcKrW9pRiZzanEJ2E, { description: 'update group description }) + # + # @param [String] id(required) The ID of a group. + # @param [Hash] options A customizable set of options. + # @option options [String] :description New optional description for the group. A group description cannot be larger than 200 characters. If value is not defined, then description is not changed. + # @option options [String] :name New optional name for the group. A group name cannot be larger than 255 characters and must be unique. Value 'anyone' (whatever the case) is reserved and cannot be used. If value is empty or not defined, then name is not changed. + # @return [Array] + def update_group(id, options = {}) + post('/api/user_groups/update', body: { id: id }.merge!(options)) + end + alias group_update update_group + + # Add a user to a group. + # + # @example + # Sonarqube.add_member('AXQRcKrW9pRiZzanEJ2E', 'test-user') + # Sonarqube.add_member('AXQRcKrW9pRiZzanEJ2E', 'test-user', {name: 'sonar-groups'}) + # + # @param [String] id(required) The id of group. + # @param [String] login(required) The login of user. + # @param [Hash] options A customizable set of options. + # @option options [String] :name Optional name of group. + # @return [Sonarqube::ObjectifiedHash] + def add_member(id = nil, login = nil, options = {}) + raise ArgumentError, 'Missing required parameters' if id.nil? || login.nil? + + post('/api/user_groups/add_user', body: { id: id, login: login }.merge!(options)) + end + alias member_add add_member + + # Reomve a user to a group. + # + # @example + # Sonarqube.remove_member('AXQRcKrW9pRiZzanEJ2E', 'test-user') + # Sonarqube.remove_member('AXQRcKrW9pRiZzanEJ2E', 'test-user', {name: 'sonar-groups'}) + # + # @param [String] id(required) The id of group. + # @param [String] login(required) The login of user. + # @param [Hash] options A customizable set of options. + # @option options [String] :name Optional name of group. + # @return [Sonarqube::ObjectifiedHash] + def remove_member(id = nil, login = nil, options = {}) + raise ArgumentError, 'Missing required parameters' if id.nil? || login.nil? + + post('/api/user_groups/remove_user', body: { id: id, login: login }.merge!(options)) + end + alias member_remove remove_member + + # List members of group. + # + # @example + # Sonarqube.list_members({id: 'AXQRcKrW9pRiZzanEJ2E'}) + # Sonarqube.list_members({name: 'sonar-groups'}) + # + # @param [Hash] options A customizable set of options. + # @option options [String] :name(required) Name of group. + # @option options [String] :id(required) Id of group. + # @return [Sonarqube::ObjectifiedHash] Information about added team member. + def list_members(options = {}) + raise ArgumentError, 'Missing required parameters' if options[:id].nil? && options[:name].nil? + + get('/api/user_groups/users', query: options) + end + + alias members_list list_members + end +end diff --git a/lib/sonarqube/client/projects.rb b/lib/sonarqube/client/projects.rb new file mode 100644 index 0000000..1114ef9 --- /dev/null +++ b/lib/sonarqube/client/projects.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class Sonarqube::Client + # Defines methods related to projects. + # @see https://SONAR_URL/web_api/api/projects + module Projects + # Search for projects by name. + # + # @example + # Sonarqube.project_search() + # Sonarqube.project_search({ p: 2 }) + # Sonarqube.search_projects({ ps: 42, p: 5 }) + # + # @param [Hash] options A customizable set of options. + # @option options [String] :analyzedBefore Filter the projects for which last analysis is older than the given date + # @option options [Boolean] :onProvisionedOnly Filter the projects that are provisioned + # @option options [Integer] :ps Page size number of projects to return per page + # @option options [Integer] :p The page to retrieve + # @option options [String] :qualifiers Filter the results with the specified qualifiers (TRK,VW,APP) + # @option options [String] :q Limit search to component names that contain the supplied string or component keys that contain the supplied string + # @option options [String] :projects Comma-separated list of project keys + # (Any provided options will be passed to Sonarqube. See {https://SONAR_URL/web_api/api/projects/search} + # @return [Array] + def projects_search(options = {}) + get('/api/projects/search', query: options) + end + alias search_projects projects_search + + # Creates a new project. + # + # @example + # Sonarqube.create_project('sonarqube','sonarqube) + # Sonarqube.create_project('viking', 'ragnar' { visibility: 'private' }) + # + # @param [String] name The name of a project. + # @param [String] key The key of a project. + # @param [Hash] options A customizable set of options. + # @option options [String] :visibility Visibility of a project (public or private). + # @return [Sonarqube::ObjectifiedHash] Information about created project. + def create_project(name, key = nil, options = {}) + key = name if key.nil? + post('/api/projects/create', body: { name: name, project: key }.merge(options)) + end + + alias project_create create_project + # Deletes a project. + # + # @example + # Sonarqube.delete_project(4) + # + # @param [String] key The key of project. + # @return [Sonarqube::ObjectifiedHash] Information about deleted project. + def delete_project(key) + post('/api/projects/delete', body: { project: key }) + end + + alias project_delete delete_project + # Gets a list of project hooks. + # + # @example + # Sonarqube.project_update_key(42) + # Sonarqube.project_update_key('sonarqube') + # + # @param [String] key_ori The original key of a project. + # @param [String] key_new The New key of a project. + # @return [Array] + def project_update_key(key_ori, key_new) + post('/api/projects/update_key', body: { from: key_ori, to: key_new }) + end + + alias update_key_project project_update_key + # Gets a project hook. + # + # @example + # Sonarqube.project_hook(42, 5) + # Sonarqube.project_hook('sonarqube', 5) + # + # @param [String] project The name fo project. + # @param [String] visibility The visibility of a project. + # @return [Sonarqube::ObjectifiedHash] + def project_update_visibility(project, visibility) + post('/api/projects/update_visibility', body: { project: project, visibility: visibility }) + end + + alias update_visibility_project project_update_visibility + # Bulk delete projects. + # + # @example + # Sonarqube.project_bulk_delete() + # Sonarqube.project_bulk_delete({ p: 2 }) + # Sonarqube.project_bulk_delete({ ps: 42, p: 5 }) + # + # @param [Hash] options A customizable set of options. + # @option options [String] :analyzedBefore Filter the projects for which last analysis is older than the given date. + # @option options [Boolean] :onProvisionedOnly Filter the projects that are provisioned + # @option options [String] :qualifiers Filter the results with the specified qualifiers (TRK,VW,APP) + # @option options [String] :q Limit search to component names that contain the supplied string or component keys that contain the supplied string + # @option options [String] :projects Comma-separated list of project keys + # (Any provided options will be passed to Sonarqube. See {https://SONAR_URL/web_api/api/projects/bulk_delete} + # @return [Array] + def projects_bulk_delete(options = {}) + if options[:analyzedBefore].nil? && options[:projects].nil? && options[:q].nil? + raise ArgumentError, 'Missing required parameters' + end + + post('/api/projects/bulk_delete', body: options) + end + + alias delete_bulk_projects projects_bulk_delete + end +end diff --git a/lib/sonarqube/client/users.rb b/lib/sonarqube/client/users.rb new file mode 100644 index 0000000..df01336 --- /dev/null +++ b/lib/sonarqube/client/users.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +class Sonarqube::Client + # Defines methods related to users. + # @see https://SONAR_URL/web_api/api/users + module Users + # Gets a list of users. + # + # @example + # Sonarqube.users_search() + # Sonarqube.users_search(query: { p: 1, ps: 10 }) + # Sonarqube.users_search(query: { p: 1, ps: 10, q: 'sonarqube' }) + # + # @param [Hash] options A customizable set of options. + # @option options [Integer] :ps Page size number of users to return per page + # @option options [Integer] :p The page to retrieve + # @option options [String] :q Filter on login, name and email + # @return [Array] + def users_search(options = {}) + get('/api/users/search', query: options) + end + + alias search_users users_search + # Creates a new user. + # Requires authentication from an admin account. + # + # @example + # Sonarqube.create_user('joe', 'joe', 'secret', , { mail: 'joe@foo.org' }) + # or + # Sonarqube.create_user('joe', 'joe', 'secret') + # + # @param [String] name(required) The name of a user. + # @param [String] login(required) The login of a user. + # @param [String] password(required only is local user) The password of a user. + # @param [Hash] options A customizable set of options. + # @option options [String] :email The emails of a user. + # @option options [String] :local Specify if the user should be authenticated from SonarQube server or from an external authentication system. Password should not be set when local is set to false. + # @option options [String] :scmAccount List of SCM accounts. To set several values, the parameter must be called once for each value. + # @param [Hash] options A customizable set of options. + # @return [Sonarqube::ObjectifiedHash] Information about created user. + def create_user(login, name, password = nil, options = {}) + body = { login: login, password: password, name: name } + body.merge!(options) + post('/api/users/create', body: body) + end + + alias user_create create_user + # Updates a user. + # + # @example + # Sonarqube.update_user('joe', { email: 'joe.smith@foo.org', name: 'Joe' }) + # + # @param [String] login(required) The login of a user + # @param [Hash] options A customizable set of options. + # @option options [String] :email The email of a user. + # @option options [String] :name The name of a user. + # @option options [String] :scmAccount SCM accounts. To set several values, the parameter must be called once for each value. + # @param [Hash] options A customizable set of options. + # @return [Sonarqube::ObjectifiedHash] Information about update user. + def update_user(login, options = {}) + post('/api/users/update', body: { login: login }.merge!(options)) + end + + alias user_update update_user + # Blocks the specified user. Available only for admin. + # + # @example + # Sonarqube.block_user(15) + # + # @param [String] login(required) The login of a user + # @param [Hash] options A customizable set of options. + def deactivate_user(login, options = {}) + post('/api/users/deactivate', body: { login: login }.merge!(options)) + end + + alias user_deactivate deactivate_user + # Change password the specified user. Available only for admin. + # + # @example + # Sonarqube.change_password_user('joe', 'password') + # Sonarqube.change_password_user('admin', 'password', admin) + # + # @param [String] login(required) The login of a user + # @param [String] password(required) New password for login + # @param [String] old_password(optional) Previous password. Required when changing one's own password. + # @param [Hash] options A customizable set of options. + def change_password_user(login, password, old_password = nil, options = {}) + body = { login: login, password: password } + body = { old_password: old_password }.merge!(body) unless old_password.nil? + post('/api/users/change_password', body: body.merge!(options)) + end + + alias user_change_password change_password_user + # Creates a new user session. + # + # @example + # Sonarqube.session('jack@example.com', 'secret12345') + # + # @param [String] login(required) The login of a user + # @param [String] new_login(required) The new login of a user + # @param [Hash] options A customizable set of options. + # @return [Sonarqube::ObjectifiedHash] + def update_login_user(login, new_login, options = {}) + post('/api/users/update_login', body: { login: login, newLogin: new_login }.merge!(options)) + end + + alias user_update_login update_login_user + # Lists the groups a user belongs to. + # + # @example + # Sonarqube.group_user + # + # @param [String] login A customizable set of options. + # @param [Hash] options A customizable set of options. + # @option options [Integer] :page The page number. + # @option options [Integer] :per_page The number of results per page. + # @option options [String] :from The start date for paginated results. + # @return [Array] + def groups_user(login, options = {}) + get('/api/users/groups', query: { login: login }.merge!(options)) + end + + alias user_groups groups_user + end +end diff --git a/lib/sonarqube/configuration.rb b/lib/sonarqube/configuration.rb new file mode 100644 index 0000000..c4112c4 --- /dev/null +++ b/lib/sonarqube/configuration.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Sonarqube + # Defines constants and methods related to configuration. + module Configuration + # An array of valid keys in the options hash when configuring a Sonarqube::API. + VALID_OPTIONS_KEYS = %i[endpoint private_token user_agent httparty].freeze + + # The user agent that will be sent to the API endpoint if none is set. + DEFAULT_USER_AGENT = "Sonarqube Ruby Gem #{Sonarqube::VERSION}" + + # @private + attr_accessor(*VALID_OPTIONS_KEYS) + # @private + alias auth_token= private_token= + + # Sets all configuration options to their default values + # when this module is extended. + def self.extended(base) + base.reset + end + + # Convenience method to allow configuration options to be set in a block. + def configure + yield self + end + + # Creates a hash of options and their values. + def options + VALID_OPTIONS_KEYS.inject({}) do |option, key| + option.merge!(key => send(key)) + end + end + + # Resets all configuration options to the defaults. + def reset + self.endpoint = ENV['SONARQUBE_API_ENDPOINT'] + self.private_token = ENV['SONARQUBE_API_PRIVATE_TOKEN'] + self.httparty = get_httparty_config(ENV['SONARQUBE_API_HTTPARTY_OPTIONS']) + self.user_agent = DEFAULT_USER_AGENT + end + + private + + # Allows HTTParty config to be specified in ENV using YAML hash. + def get_httparty_config(options) + return if options.nil? + + httparty = Sonarqube::CLI::Helpers.yaml_load(options) + raise ArgumentError, 'HTTParty config should be a Hash.' unless httparty.is_a? Hash + + Sonarqube::CLI::Helpers.symbolize_keys httparty + end + end +end diff --git a/lib/sonarqube/error.rb b/lib/sonarqube/error.rb new file mode 100644 index 0000000..12d0664 --- /dev/null +++ b/lib/sonarqube/error.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module Sonarqube + module Error + # Custom error class for rescuing from all Sonarqube errors. + class Error < StandardError; end + + # Raised when API endpoint credentials not configured. + class MissingCredentials < Error; end + + # Raised when impossible to parse response body. + class Parsing < Error; end + + # Custom error class for rescuing from HTTP response errors. + class ResponseError < Error + POSSIBLE_MESSAGE_KEYS = %i[message error_description error].freeze + + def initialize(response) + @response = response + super(build_error_message) + end + + # Status code returned in the HTTP response. + # + # @return [Integer] + def response_status + @response.code + end + + # Body content returned in the HTTP response + # + # @return [String] + def response_message + @response.parsed_response.message + end + + # Additional error context returned by some API endpoints + # + # @return [String] + def error_code + if @response.respond_to?(:error_code) + @response.error_code + else + '' + end + end + + private + + # Human friendly message. + # + # @return [String] + def build_error_message + parsed_response = classified_response + message = check_error_keys(parsed_response) + "Server responded with code #{@response.code}, message: " \ + "#{handle_message(message)}. " \ + "Request URI: #{@response.request.base_uri}#{@response.request.path}" + end + + # Error keys vary across the API, find the first key that the parsed_response + # object responds to and return that, otherwise return the original. + def check_error_keys(resp) + key = POSSIBLE_MESSAGE_KEYS.find { |k| resp.respond_to?(k) } + key ? resp.send(key) : resp + end + + # Parse the body based on the classification of the body content type + # + # @return parsed response + def classified_response + if @response.respond_to?('headers') + @response.headers['content-type'] == 'text/plain' ? { message: @response.to_s } : @response.parsed_response + else + @response.parsed_response + end + rescue Sonarqube::Error::Parsing + # Return stringified response when receiving a + # parsing error to avoid obfuscation of the + # api error. + # + # note: The Sonarqube API does not always return valid + # JSON when there are errors. + @response.to_s + end + + # Handle error response message in case of nested hashes + def handle_message(message) + case message + when Sonarqube::ObjectifiedHash + message.to_h.sort.map do |key, val| + "'#{key}' #{(val.is_a?(Hash) ? val.sort.map { |k, v| "(#{k}: #{v.join(' ')})" } : [val].flatten).join(' ')}" + end.join(', ') + when Array + message.join(' ') + else + message + end + end + end + + # Raised when API endpoint returns the HTTP status code 400. + class BadRequest < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 401. + class Unauthorized < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 403. + class Forbidden < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 404. + class NotFound < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 405. + class MethodNotAllowed < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 406. + class NotAcceptable < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 409. + class Conflict < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 422. + class Unprocessable < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 429. + class TooManyRequests < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 500. + class InternalServerError < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 502. + class BadGateway < ResponseError; end + + # Raised when API endpoint returns the HTTP status code 503. + class ServiceUnavailable < ResponseError; end + + # HTTP status codes mapped to error classes. + STATUS_MAPPINGS = { + 400 => BadRequest, + 401 => Unauthorized, + 403 => Forbidden, + 404 => NotFound, + 405 => MethodNotAllowed, + 406 => NotAcceptable, + 409 => Conflict, + 422 => Unprocessable, + 429 => TooManyRequests, + 500 => InternalServerError, + 502 => BadGateway, + 503 => ServiceUnavailable + }.freeze + end +end diff --git a/lib/sonarqube/objectified_hash.rb b/lib/sonarqube/objectified_hash.rb new file mode 100644 index 0000000..071ae7b --- /dev/null +++ b/lib/sonarqube/objectified_hash.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Sonarqube + # Converts hashes to the objects. + class ObjectifiedHash + # Creates a new ObjectifiedHash object. + def initialize(hash) + @hash = hash + @data = hash.each_with_object({}) do |(key, value), data| + value = self.class.new(value) if value.is_a? Hash + value = value.map { |v| v.is_a?(Hash) ? self.class.new(v) : v } if value.is_a? Array + data[key.to_s] = value + end + end + + # @return [Hash] The original hash. + def to_hash + hash + end + alias to_h to_hash + + # @return [String] Formatted string with the class name, object id and original hash. + def inspect + "#<#{self.class}:#{object_id} {hash: #{hash.inspect}}" + end + + def [](key) + data[key] + end + + private + + attr_reader :hash, :data + + # Respond to messages for which `self.data` has a key + def method_missing(method_name, *args, &block) + if data.key?(method_name.to_s) + data[method_name.to_s] + elsif data.respond_to?(method_name) + warn 'WARNING: Please convert ObjectifiedHash object to hash before calling Hash methods on it.' + data.send(method_name, *args, &block) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + hash.keys.map(&:to_sym).include?(method_name.to_sym) || super + end + end +end diff --git a/lib/sonarqube/request.rb b/lib/sonarqube/request.rb new file mode 100644 index 0000000..be1364f --- /dev/null +++ b/lib/sonarqube/request.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'httparty' +require 'json' + +module Sonarqube + # @private + class Request + include HTTParty + format :json + headers 'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded' + parser(proc { |body, _| parse(body) }) + + attr_accessor :private_token, :endpoint + + # Converts the response body to an ObjectifiedHash. + def self.parse(body) + body = decode(body) + + if body.is_a? Hash + ObjectifiedHash.new body + elsif body + true + elsif !body + false + elsif body.nil? + false + else + raise Error::Parsing, "Couldn't parse a response body" + end + end + + # Decodes a JSON response into Ruby object. + def self.decode(response) + response ? JSON.load(response) : {} + rescue JSON::ParserError + raise Error::Parsing, 'The response is not a valid JSON' + end + + %w[get post put delete].each do |method| + define_method method do |path, options = {}| + params = options.dup + + httparty_config(params) + + unless params[:unauthenticated] + params[:headers] ||= {} + params[:headers].merge!(authorization_header) + end + + validate self.class.send(method, @endpoint + path, params) + end + end + + # Checks the response code for common errors. + # Returns parsed response for successful requests. + def validate(response) + error_klass = Error::STATUS_MAPPINGS[response.code] + raise error_klass, response if error_klass + + parsed = response.parsed_response + parsed.client = self if parsed.respond_to?(:client=) + parsed.parse_headers!(response.headers) if parsed.respond_to?(:parse_headers!) + parsed + end + + # Sets a base_uri and default_params for requests. + # @raise [Error::MissingCredentials] if endpoint not set. + def request_defaults + raise Error::MissingCredentials, 'Please set an endpoint to API' unless @endpoint + + self.class.default_params + end + + private + + # Returns an Authorization header hash + # + # @raise [Error::MissingCredentials] if private_token and auth_token are not set. + def authorization_header + raise Error::MissingCredentials, 'Please provide a private_token for user' unless @private_token + + { 'Authorization' => "Basic #{Base64.encode64("#{private_token}:")}" } + end + + # Set HTTParty configuration + # @see https://github.com/jnunemaker/httparty + def httparty_config(options) + options.merge!(httparty) if httparty + end + end +end diff --git a/lib/sonarqube/version.rb b/lib/sonarqube/version.rb new file mode 100644 index 0000000..8d5bbb1 --- /dev/null +++ b/lib/sonarqube/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Sonarqube + VERSION = '1.0.0' +end diff --git a/sonarqube.gemspec b/sonarqube.gemspec new file mode 100644 index 0000000..edf8c6b --- /dev/null +++ b/sonarqube.gemspec @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'sonarqube/version' + +Gem::Specification.new do |gem| + gem.name = 'sonarqube' + gem.version = Sonarqube::VERSION + gem.authors = ['Mariani Lucas'] + gem.email = ['marianilucas@gmail.com'] + gem.description = 'Ruby client for Sonarqube API' + gem.summary = 'A Ruby wrapper for the Sonarqube API' + gem.homepage = 'https://github.com/psyreactor/sonarqube-ruby' + + gem.files = Dir['{lib}/**/*', 'LICENSE.txt', 'README.md', 'CHANGELOG.md'] + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.require_paths = ['lib'] + gem.license = 'BSD-2-Clause' + + gem.required_ruby_version = '>= 2.5' + + gem.add_runtime_dependency 'httparty', '~> 0.14', '>= 0.14.0' + gem.add_runtime_dependency 'terminal-table', '~> 1.5', '>= 1.5.1' + + gem.add_development_dependency 'rake' + gem.add_development_dependency 'rspec' + gem.add_development_dependency 'webmock' +end diff --git a/spec/fixtures/group.json b/spec/fixtures/group.json new file mode 100644 index 0000000..7c2bcc5 --- /dev/null +++ b/spec/fixtures/group.json @@ -0,0 +1 @@ +{"paging":{"pageIndex":1,"pageSize":100,"total":1},"groups":[{"uuid":"AXOt93S3gMZPhbn-E_O7","name":"sonar-users","description":"Any new users created will automatically join this group","membersCount":1,"default":true}]} \ No newline at end of file diff --git a/spec/fixtures/group_create.json b/spec/fixtures/group_create.json new file mode 100644 index 0000000..9e5e048 --- /dev/null +++ b/spec/fixtures/group_create.json @@ -0,0 +1 @@ +{"group":{"uuid":"AXQRHewQ9pRiZzanEJ15","organization":"default-organization","name":"Sonarqube-Group","membersCount":0,"default":false}} \ No newline at end of file diff --git a/spec/fixtures/group_create_with_description.json b/spec/fixtures/group_create_with_description.json new file mode 100644 index 0000000..6615d8f --- /dev/null +++ b/spec/fixtures/group_create_with_description.json @@ -0,0 +1 @@ +{"group":{"uuid":"AXQRJ0fk9pRiZzanEJ2D","organization":"default-organization","name":"Sonarqube-Group","description":"Sonarqube group users","membersCount":0,"default":false}} \ No newline at end of file diff --git a/spec/fixtures/group_delete.json b/spec/fixtures/group_delete.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/group_delete.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/group_update.json b/spec/fixtures/group_update.json new file mode 100644 index 0000000..fee5b1e --- /dev/null +++ b/spec/fixtures/group_update.json @@ -0,0 +1 @@ +{"group":{"uuid":"AXQRcKrW9pRiZzanEJ2E","organization":"default-organization","name":"Sonarqube-Group","description":"New description group","membersCount":0,"default":false}} \ No newline at end of file diff --git a/spec/fixtures/groups.json b/spec/fixtures/groups.json new file mode 100644 index 0000000..ce731cd --- /dev/null +++ b/spec/fixtures/groups.json @@ -0,0 +1 @@ +{"paging":{"pageIndex":1,"pageSize":100,"total":2},"groups":[{"uuid":"AXOt93S3gMZPhbn-E_O6","name":"sonar-administrators","description":"System administrators","membersCount":1,"default":false},{"uuid":"AXOt93S3gMZPhbn-E_O7","name":"sonar-users","description":"Any new users created will automatically join this group","membersCount":1,"default":true}]} \ No newline at end of file diff --git a/spec/fixtures/member_add.json b/spec/fixtures/member_add.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/member_add.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/member_remove.json b/spec/fixtures/member_remove.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/member_remove.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/members_list.json b/spec/fixtures/members_list.json new file mode 100644 index 0000000..22d1f1f --- /dev/null +++ b/spec/fixtures/members_list.json @@ -0,0 +1 @@ +{"users":[{"login":"admin","name":"Administrator","selected":true},{"login":"test","name":"test","selected":true}],"p":1,"ps":25,"total":2} \ No newline at end of file diff --git a/spec/fixtures/project.json b/spec/fixtures/project.json new file mode 100644 index 0000000..cb223ab --- /dev/null +++ b/spec/fixtures/project.json @@ -0,0 +1 @@ +{"project":{"key":"new_key","name":"new_project","qualifier":"TRK","visibility":"private"}} \ No newline at end of file diff --git a/spec/fixtures/project_delete.json b/spec/fixtures/project_delete.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/project_delete.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/project_update_key.json b/spec/fixtures/project_update_key.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/project_update_key.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/project_update_visibility.json b/spec/fixtures/project_update_visibility.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/project_update_visibility.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/projects_bulk_delete.json b/spec/fixtures/projects_bulk_delete.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/projects_bulk_delete.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/projects_search.json b/spec/fixtures/projects_search.json new file mode 100644 index 0000000..0baedf3 --- /dev/null +++ b/spec/fixtures/projects_search.json @@ -0,0 +1 @@ +{"paging":{"pageIndex":1,"pageSize":100,"total":1},"components":[{"organization":"default-organization","key":"test","name":"test","qualifier":"TRK","visibility":"public"}]} \ No newline at end of file diff --git a/spec/fixtures/user_change_password.json b/spec/fixtures/user_change_password.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/user_change_password.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/user_create.json b/spec/fixtures/user_create.json new file mode 100644 index 0000000..2c2458e --- /dev/null +++ b/spec/fixtures/user_create.json @@ -0,0 +1 @@ +{"user":{"login":"test","name":"test QA","scmAccounts":[],"active":true,"local":true}} \ No newline at end of file diff --git a/spec/fixtures/user_deactivate.json b/spec/fixtures/user_deactivate.json new file mode 100644 index 0000000..9a6e4a7 --- /dev/null +++ b/spec/fixtures/user_deactivate.json @@ -0,0 +1 @@ +{"user":{"login":"test","name":"test","active":false,"local":true,"externalIdentity":"test","externalProvider":"sonarqube","groups":[],"scmAccounts":[]}} \ No newline at end of file diff --git a/spec/fixtures/user_groups.json b/spec/fixtures/user_groups.json new file mode 100644 index 0000000..6680af7 --- /dev/null +++ b/spec/fixtures/user_groups.json @@ -0,0 +1 @@ +{"paging":{"pageIndex":1,"pageSize":25,"total":1},"groups":[{"id":"AXOt93S3gMZPhbn-E_O7","name":"sonar-users","description":"Any new users created will automatically join this group","selected":true,"default":true}]} \ No newline at end of file diff --git a/spec/fixtures/user_update.json b/spec/fixtures/user_update.json new file mode 100644 index 0000000..c62bd43 --- /dev/null +++ b/spec/fixtures/user_update.json @@ -0,0 +1 @@ +{"user":{"login":"test","name":"test","email":"test@local.ad","active":true,"local":true,"externalIdentity":"test","externalProvider":"sonarqube","groups":[],"scmAccounts":[]}} \ No newline at end of file diff --git a/spec/fixtures/user_update_login.json b/spec/fixtures/user_update_login.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/spec/fixtures/user_update_login.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/spec/fixtures/users.json b/spec/fixtures/users.json new file mode 100644 index 0000000..5f767c9 --- /dev/null +++ b/spec/fixtures/users.json @@ -0,0 +1 @@ +{"paging":{"pageIndex":1,"pageSize":50,"total":2},"users":[{"login":"admin","name":"Administrator","active":true,"groups":["sonar-administrators","sonar-users"],"tokensCount":1,"local":true,"externalIdentity":"admin","externalProvider":"sonarqube","lastConnectionDate":"2020-08-22T19:03:05+0000"},{"login":"test","name":"test","active":true,"email":"test@local.ad","groups":["Sonarqube-Group","sonar-users"],"tokensCount":0,"local":true,"externalIdentity":"test","externalProvider":"sonarqube","avatar":"30733e027a560af76b5142c5f521fb41"}]} \ No newline at end of file diff --git a/spec/sonarqube/api_spec.rb b/spec/sonarqube/api_spec.rb new file mode 100644 index 0000000..1c67e0d --- /dev/null +++ b/spec/sonarqube/api_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::API do + let(:default_headers) { subject.class.default_options[:headers] } + + describe '.default_options[:headers]' do + it "has 'User-Agent'" do + expect(default_headers).to include('User-Agent' => Sonarqube::Configuration::DEFAULT_USER_AGENT) + end + end +end diff --git a/spec/sonarqube/client/groups_spec.rb b/spec/sonarqube/client/groups_spec.rb new file mode 100644 index 0000000..4962bf1 --- /dev/null +++ b/spec/sonarqube/client/groups_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::Client do + describe '.groups' do + before do + stub_get('/api/user_groups/search', 'group').with(query: { 'q' => 'sonar-users' }) + @group = Sonarqube.search_groups({ 'q' => 'sonar-users' }) + end + + it 'gets the correct resource' do + expect(a_get('/api/user_groups/search') + .with(query: { 'q' => 'sonar-users' })).to have_been_made + end + end + + describe '.create_group' do + context 'without description' do + before do + stub_post('/api/user_groups/create', 'group_create') + @group = Sonarqube.create_group('Sonarqube-Group') + end + + it 'gets the correct resource' do + expect(a_post('/api/user_groups/create') + .with(body: { name: 'Sonarqube-Group' })).to have_been_made + end + + it 'returns information about a created group' do + expect(@group.group.name).to eq('Sonarqube-Group') + end + end + + context 'with description' do + before do + stub_post('/api/user_groups/create', 'group_create_with_description') + @group = Sonarqube.create_group('Sonarqube-Group', { description: 'Sonarqube group users' }) + end + + it 'gets the correct resource' do + expect(a_post('/api/user_groups/create') + .with(body: { name: 'Sonarqube-Group', + description: 'Sonarqube group users' })).to have_been_made + end + + it 'returns information about a created group' do + expect(@group.group.name).to eq('Sonarqube-Group') + expect(@group.group.description).to eq('Sonarqube group users') + end + end + end + + describe '.delete_group' do + before do + stub_post('/api/user_groups/delete', 'group_delete') + @group = Sonarqube.delete_group('Sonarqube-Group') + end + + it 'gets the correct resource' do + expect(a_post('/api/user_groups/delete') + .with(body: { name: 'Sonarqube-Group' })).to have_been_made + end + + it 'returns information about a deleted group' do + expect(@group.to_hash).to be_empty + end + end + + describe '.update_group' do + before do + stub_post('/api/user_groups/update', 'group_update') + @group = Sonarqube.update_group('AXQRcKrW9pRiZzanEJ2E', { description: 'New description group' }) + end + + it 'posts to the correct resource' do + expect(a_post('/api/user_groups/update') + .with(body: { id: 'AXQRcKrW9pRiZzanEJ2E', + description: 'New description group' })).to have_been_made + end + + it 'returns information about the group' do + expect(@group.group.description).to eq('New description group') + end + end + + describe '.member_add' do + before do + stub_post('/api/user_groups/add_user', 'member_add') + @member = Sonarqube.member_add('AXQRcKrW9pRiZzanEJ2E', 'test-user') + end + + it 'gets the correct resource' do + expect(a_post('/api/user_groups/add_user') + .with(body: { id: 'AXQRcKrW9pRiZzanEJ2E', + login: 'test-user' })).to have_been_made + end + + it "returns information about a group's members" do + expect(@member).to be_a Sonarqube::ObjectifiedHash + expect(@member.to_hash).to be_empty + end + end + + describe '.member_remove' do + before do + stub_post('/api/user_groups/remove_user', 'member_remove') + @member = Sonarqube.member_remove('AXQRcKrW9pRiZzanEJ2E', 'test-user') + end + + it 'gets the correct resource' do + expect(a_post('/api/user_groups/remove_user') + .with(body: { id: 'AXQRcKrW9pRiZzanEJ2E', + login: 'test-user' })).to have_been_made + end + + it 'returns information about a group member' do + expect(@member).to be_a Sonarqube::ObjectifiedHash + expect(@member.to_hash).to be_empty + end + end + + describe '.members_list' do + before do + stub_get('/api/user_groups/users', 'members_list').with(query: { 'id' => 'AXQRcKrW9pRiZzanEJ2E' }) + @member = Sonarqube.members_list({ id: 'AXQRcKrW9pRiZzanEJ2E' }) + end + + it 'gets the correct resource' do + expect(a_get('/api/user_groups/users') + .with(query: { 'id' => 'AXQRcKrW9pRiZzanEJ2E' })).to have_been_made + end + + it 'returns information about the edited member' do + expect(@member).to be_a Sonarqube::ObjectifiedHash + expect(@member.users[0].name).to eq('Administrator') + end + end +end diff --git a/spec/sonarqube/client/projects_spec.rb b/spec/sonarqube/client/projects_spec.rb new file mode 100644 index 0000000..d12c0a8 --- /dev/null +++ b/spec/sonarqube/client/projects_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::Client do + it { is_expected.to respond_to :search_projects } + + describe '.project_search' do + before do + stub_get('/api/projects/search', 'projects_search') + @projects_search = Sonarqube.projects_search + end + + it 'gets the correct resource' do + expect(a_get('/api/projects/search')).to have_been_made + end + + it 'returns response of projects found' do + expect(@projects_search).to be_a Sonarqube::ObjectifiedHash + expect(@projects_search.components.first.name).to eq('test') + expect(@projects_search.components.first.organization).to eq('default-organization') + end + end + + describe '.project_create' do + before do + stub_post('/api/projects/create', 'project') + @project = Sonarqube.project_create('new_project', 'new_key', { visibility: 'private' }) + end + + it 'gets the correct resource' do + expect(a_post('/api/projects/create')).to have_been_made + end + + it 'returns information about a created project' do + expect(@project.project.name).to eq('new_project') + expect(@project.project.key).to eq('new_key') + expect(@project.project.visibility).to eq('private') + end + end + + describe '.project_delete' do + before do + stub_post('/api/projects/delete', 'project_delete') + @project = Sonarqube.project_delete('Sonarqube') + end + + it 'gets the correct resource' do + expect(a_post('/api/projects/delete')).to have_been_made + end + + it 'returns information about a deleted project' do + expect(@project.to_hash).to be_empty + end + end + + describe '.project_update_key' do + before do + stub_post('/api/projects/update_key', 'project_update_key') + @project = Sonarqube.project_update_key('old_key', 'new_key') + end + + it 'posts to the correct resource' do + expect(a_post('/api/projects/update_key')).to have_been_made + end + + it 'returns information about a update project key' do + expect(@project.to_hash).to be_empty + end + end + + describe '.project_update_visibility' do + before do + stub_post('/api/projects/update_visibility', 'project_update_visibility') + @project_update_visibility = Sonarqube.project_update_visibility('new_project', 'public') + end + + it 'gets the correct resource' do + expect(a_post('/api/projects/update_visibility')).to have_been_made + end + + it 'returns a response of projects update visibility' do + expect(@project_update_visibility.to_hash).to be_empty + end + end + + describe '.projects_bulk_delete' do + before do + stub_post('/api/projects/bulk_delete', 'projects_bulk_delete') + @projects_bulk_delete = Sonarqube.projects_bulk_delete({ projects: 'old_project' }) + end + + it 'gets the correct resource' do + expect(a_post('/api/projects/bulk_delete')).to have_been_made + end + + it 'returns a response of projects bulk delete' do + expect(@projects_bulk_delete.to_hash).to be_empty + end + end +end diff --git a/spec/sonarqube/client/users_spec.rb b/spec/sonarqube/client/users_spec.rb new file mode 100644 index 0000000..8cce4a6 --- /dev/null +++ b/spec/sonarqube/client/users_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::Client do + describe '.users_search' do + before do + stub_get('/api/users/search', 'users') + @users = Sonarqube.users_search + end + + it 'gets the correct resource' do + expect(a_get('/api/users/search')).to have_been_made + end + + it 'returns a paginated response of users' do + expect(@users).to be_a Sonarqube::ObjectifiedHash + expect(@users.users.first.login).to eq('admin') + end + end + + describe '.user_create' do + context 'when successful request' do + before do + stub_post('/api/users/create', 'user_create') + @user = Sonarqube.user_create('test', 'test QA', 'secretpassword') + end + + it 'gets the correct resource' do + body = { login: 'test', password: 'secretpassword', name: 'test QA' } + expect(a_post('/api/users/create').with(body: body)).to have_been_made + end + + it 'returns information about a created user' do + expect(@user.user.login).to eq('test') + end + end + end + + describe '.user_update' do + before do + stub_post('/api/users/update', 'user_update').with(body: { login: 'test', description: 'Test New QA' }) + @user = Sonarqube.user_update('test', { description: 'Test New QA' }) + end + + it 'gets the correct resource' do + expect(a_post('/api/users/update').with(body: { login: 'test', description: 'Test New QA' })).to have_been_made + end + + it 'returns information about a update user' do + expect(@user.user.login).to eq('test') + end + end + + describe '.user_deactivate' do + before do + stub_post('/api/users/deactivate', 'user_deactivate') + @user = Sonarqube.user_deactivate('test') + end + + it 'gets the correct resource' do + expect(a_post('/api/users/deactivate')).to have_been_made + end + + it 'returns information about a deactivate user' do + expect(@user.user.login).to eq('test') + end + end + + describe '.user_change_password' do + before do + stub_post('/api/users/change_password', 'user_change_password') + @result = Sonarqube.user_change_password('test', 'newpassword') + end + + it 'gets the correct resource' do + expect(a_post('/api/users/change_password')).to have_been_made + end + + it 'returns information about a change user password' do + expect(@result.to_hash).to be_empty + end + end + + describe '.user_update_login' do + before do + stub_post('/api/users/update_login', 'user_update_login') + @result = Sonarqube.user_update_login('test', 'newlogin') + end + + it 'gets the correct resource' do + expect(a_post('/api/users/update_login')).to have_been_made + end + + it 'returns information about a change user login' do + expect(@result.to_hash).to be_empty + end + end + + describe '.user_groups' do + before do + stub_get('/api/users/groups', 'user_groups').with(query: { login: 'test' }) + @result = Sonarqube.user_groups('test') + end + + it 'gets the correct resource' do + expect(a_get('/api/users/groups').with(query: { login: 'test' })).to have_been_made + end + + it 'returns boolean' do + expect(@result.groups.first.name).to eq('sonar-users') + end + end +end diff --git a/spec/sonarqube/error_spec.rb b/spec/sonarqube/error_spec.rb new file mode 100644 index 0000000..39d3ce2 --- /dev/null +++ b/spec/sonarqube/error_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::Error::ResponseError do + before do + @request_double = double(base_uri: 'https://sonarqube.com/api/v3', path: '/foo') + end + + let(:expected_messages) do + [ + %r{Server responded with code \d+, message: Displayed message. Request URI: https://sonarqube.com/api/v3/foo}, + %r{Server responded with code \d+, message: Displayed error_description. Request URI: https://sonarqube.com/api/v3/foo}, + %r{Server responded with code \d+, message: Displayed error. Request URI: https://sonarqube.com/api/v3/foo}, + %r{Server responded with code \d+, message: 'embed_entity' \(foo: bar\) \(sna: fu\), 'password' too short. Request URI: https://sonarqube.com/api/v3/foo}, + %r{Server responded with code \d+, message: First message. Second message.. Request URI: https://sonarqube.com/api/v3/foo}, + %r{Server responded with code \d+, message: 'error' Spam detected. Request URI: https://sonarqube.com/api/v3/foo} + ] + end + + # Set up some response scenarios to test. + [ + { code: 401, parsed_response: Sonarqube::ObjectifiedHash.new(message: 'Displayed message', error_description: 'should not be displayed', error: 'also will not be displayed') }, + { code: 404, parsed_response: Sonarqube::ObjectifiedHash.new(error_description: 'Displayed error_description', error: 'also will not be displayed') }, + { code: 401, parsed_response: Sonarqube::ObjectifiedHash.new(error: 'Displayed error') }, + { code: 500, parsed_response: Sonarqube::ObjectifiedHash.new(embed_entity: { foo: ['bar'], sna: ['fu'] }, password: ['too short']) }, + { code: 403, parsed_response: Array.new(['First message.', 'Second message.']) }, + { code: 400, parsed_response: Sonarqube::ObjectifiedHash.new(message: { error: 'Spam detected' }) } + + ].each_with_index do |data, index| + it 'returns the expected message' do + response_double = double(**data, request: @request_double) + expect(described_class.new(response_double).message).to match expected_messages[index] + end + end +end + +describe Sonarqube::Error::ResponseError do + before do + @request_double = double('request', base_uri: 'https://sonarqube.com/api/v3', path: '/foo', options: {}) + end + + it 'Builds an error message from text' do + headers = { 'content-type' => 'text/plain' } + response_double = double('response', body: 'Retry later', to_s: 'Retry text', parsed_response: { message: 'Retry hash' }, code: 429, options: {}, headers: headers, request: @request_double) + expect(described_class.new(response_double).send(:build_error_message)).to match(/Retry text/) + end + + it 'Builds an error message from parsed json' do + headers = { 'content-type' => 'application/json' } + response_double = double('response', body: 'Retry later', to_s: 'Retry text', parsed_response: { message: 'Retry hash' }, code: 429, options: {}, headers: headers, request: @request_double) + expect(described_class.new(response_double).send(:build_error_message)).to match(/Retry hash/) + end + + context 'parsing errors' do + let(:headers) { { 'content-type' => 'application/json' } } + let(:response_double) do + double('response', body: 'Retry later', to_s: 'Retry text', code: status, options: {}, headers: headers, request: @request_double) + end + let(:status) { 429 } + + before do + allow(response_double).to receive(:parsed_response) + .and_raise(Sonarqube::Error::Parsing) + end + + it 'Builds an error message from text' do + expect(described_class.new(response_double).send(:build_error_message)).to match(/Retry text/) + end + end + + describe '#error_code' do + it 'returns the value when available' do + headers = { 'content-type' => 'application/json' } + response_double = double( + 'response', + body: 'Retry later', + to_s: 'Retry text', + parsed_response: { message: 'Retry hash' }, + code: 400, + error_code: 'conflict', + options: {}, + headers: headers, + request: @request_double + ) + + expect(described_class.new(response_double).error_code).to eq 'conflict' + end + + it 'returns nothing when unavailable' do + headers = { 'content-type' => 'application/json' } + response_double = double( + 'response', + body: 'Retry later', + to_s: 'Retry text', + parsed_response: { message: 'Retry hash' }, + code: 400, + options: {}, + headers: headers, + request: @request_double + ) + + expect(described_class.new(response_double).error_code).to eq '' + end + end +end diff --git a/spec/sonarqube/objectified_hash_spec.rb b/spec/sonarqube/objectified_hash_spec.rb new file mode 100644 index 0000000..6e72177 --- /dev/null +++ b/spec/sonarqube/objectified_hash_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::ObjectifiedHash do + before do + @hash = { a: 1, b: 2, 'string' => 'string', symbol: :symbol, array: ['string', { a: 1, b: 2 }] } + @oh = described_class.new @hash + end + + describe 'Hash behavior' do + let(:hash) { { foo: 'bar' } } + let(:oh) { described_class.new(hash) } + + it 'allows to call Hash methods' do + expect(oh['foo']).to eq('bar') + expect(oh.merge(key: :value)).to eq({ 'foo' => 'bar', key: :value }) + end + + it 'warns about calling Hash methods' do + output = capture_output { oh.values } + expect(output).to eq("WARNING: Please convert ObjectifiedHash object to hash before calling Hash methods on it.\n") + end + end + + it 'objectifies a hash' do + expect(@oh.a).to eq(@hash[:a]) + expect(@oh.b).to eq(@hash[:b]) + end + + it 'objectifies a hash contained in an array' do + expect(@oh.array[1].a).to eq(@hash[:array][1][:a]) + expect(@oh.array[1].b).to eq(@hash[:array][1][:b]) + expect(@oh.array[0]).to eq(@hash[:array][0]) + end + + it 'supports legacy addressing mode' do + expect(@oh['a']).to eq(@hash[:a]) + expect(@oh['b']).to eq(@hash[:b]) + end + + describe '#to_hash' do + it 'returns an original hash' do + expect(@oh.to_hash).to eq(@hash) + end + + it 'has an alias #to_h' do + expect(@oh).to respond_to(:to_h) + end + end + + describe '#inspect' do + it 'returns a formatted string' do + pretty_string = "#<#{@oh.class.name}:#{@oh.object_id} {hash: #{@hash}}" + expect(@oh.inspect).to eq(pretty_string) + end + end + + describe '#respond_to' do + it 'returns true for methods this object responds to through method_missing as sym' do + expect(@oh).to respond_to(:a) + end + + it 'returns true for methods this object responds to through method_missing as string' do + expect(@oh).to respond_to('string') + end + + it 'does not care if you use a string or symbol to reference a method' do + expect(@oh).to respond_to(:string) + end + + it 'does not care if you use a string or symbol to reference a method' do + expect(@oh).to respond_to('symbol') + end + end +end diff --git a/spec/sonarqube/request_spec.rb b/spec/sonarqube/request_spec.rb new file mode 100644 index 0000000..9d888f6 --- /dev/null +++ b/spec/sonarqube/request_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube::Request do + before do + # Prevent tests modifying the `default_params` value from causing cross-test + # pollution + + @request = described_class.new + end + + it { is_expected.to respond_to :get } + it { is_expected.to respond_to :post } + it { is_expected.to respond_to :put } + it { is_expected.to respond_to :delete } + + describe '.default_options' do + it 'has default values' do + default_options = described_class.default_options + expect(default_options).to be_a Hash + expect(default_options[:parser]).to be_a Proc + expect(default_options[:format]).to eq(:json) + expect(default_options[:headers]).to eq('Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded') + end + end + + describe '.parse' do + it 'returns ObjectifiedHash' do + body = JSON.unparse(a: 1, b: 2) + expect(described_class.parse(body)).to be_an Sonarqube::ObjectifiedHash + expect(described_class.parse('true')).to be true + expect(described_class.parse('false')).to be false + + expect { described_class.parse('string') }.to raise_error(Sonarqube::Error::Parsing) + end + end + + describe '#request_defaults' do + context 'when endpoint is not set' do + it 'raises Error::MissingCredentials' do + @request.endpoint = nil + expect do + @request.request_defaults + end.to raise_error(Sonarqube::Error::MissingCredentials, 'Please set an endpoint to API') + end + end + end + + describe 'HTTP request methods' do + it 'does not overwrite headers set via HTTParty configuration' do + @request.private_token = 'token' + @request.endpoint = 'https://example.com' + path = "#{@request.endpoint}/version" + + # Stub Sonarqube::Configuration + allow(@request).to receive(:httparty).and_return( + headers: { 'Cookie' => 'sonarqube_canary=true' } + ) + + stub_request(:get, path) + @request.get('/version') + + expect(a_request(:get, path).with(headers: { + 'Authorization' => "Basic #{Base64.strict_encode64('token:')}", + 'Cookie' => 'sonarqube_canary=true' + }.merge(described_class.headers))).to have_been_made + end + + it 'does not modify options in-place' do + options = { per_page: 10 } + original_options = options.dup + + @request.private_token = 'token' + @request.endpoint = 'https://example.com' + + # Stub Sonarqube::Configuration + allow(@request).to receive(:httparty).and_return(nil) + + stub_request(:get, "#{@request.endpoint}/projects_search") + @request.get('/projects_search', options) + + expect(options).to eq(original_options) + end + end + + describe '#authorization_header' do + it 'raises MissingCredentials when auth_token and private_token are not set' do + expect do + @request.send(:authorization_header) + end.to raise_error(Sonarqube::Error::MissingCredentials) + end + + it 'sets the correct header when given a private_token' do + @request.private_token = 'sjkahwu9827u2jw' + expect(@request.send(:authorization_header)).to eq('Authorization' => "Basic #{Base64.encode64("#{@request.private_token}:")}") + end + end +end diff --git a/spec/sonarqube_spec.rb b/spec/sonarqube_spec.rb new file mode 100644 index 0000000..6ec6be4 --- /dev/null +++ b/spec/sonarqube_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sonarqube do + after { described_class.reset } + + describe '.client' do + it 'is a Sonarqube::Client' do + expect(described_class.client).to be_a Sonarqube::Client + end + + it 'does not override each other' do + client1 = described_class.client(endpoint: 'https://api1.example.com', private_token: '001') + client2 = described_class.client(endpoint: 'https://api2.example.com', private_token: '002') + expect(client1.endpoint).to eq('https://api1.example.com') + expect(client2.endpoint).to eq('https://api2.example.com') + expect(client1.private_token).to eq('001') + expect(client2.private_token).to eq('002') + end + + it 'sets private_token to the auth_token when provided' do + client = described_class.client(endpoint: 'https://api2.example.com', auth_token: '3225e2804d31fea13fc41fc83bffef00cfaedc463118646b154acc6f94747603') + expect(client.private_token).to eq('3225e2804d31fea13fc41fc83bffef00cfaedc463118646b154acc6f94747603') + end + end + + describe '.actions' do + it 'returns an array of client methods' do + actions = described_class.actions + expect(actions).to be_an Array + expect(actions.first).to be_a Symbol + expect(actions.min).to eq(:add_member) + end + end + + describe '.endpoint=' do + it 'sets endpoint' do + described_class.endpoint = 'https://api.example.com' + expect(described_class.endpoint).to eq('https://api.example.com') + end + end + + describe '.private_token=' do + it 'sets private_token' do + described_class.private_token = 'secret' + expect(described_class.private_token).to eq('secret') + end + end + + describe '.auth_token=' do + it 'sets auth_token', focus: true do + described_class.auth_token = 'auth_secret' + expect(described_class.private_token).to eq('auth_secret') + end + end + + describe '.user_agent' do + it 'returns default user_agent' do + expect(described_class.user_agent).to eq(Sonarqube::Configuration::DEFAULT_USER_AGENT) + end + end + + describe '.user_agent=' do + it 'sets user_agent' do + described_class.user_agent = 'Custom User Agent' + expect(described_class.user_agent).to eq('Custom User Agent') + end + end + + describe '.configure' do + Sonarqube::Configuration::VALID_OPTIONS_KEYS.each do |key| + it "sets #{key}" do + described_class.configure do |config| + config.send("#{key}=", key) + expect(described_class.send(key)).to eq(key) + end + end + end + end + + describe '.http_proxy' do + it 'delegates the method to Sonarqube::Request' do + described_class.endpoint = 'https://api.example.com' + request = class_spy(Sonarqube::Request).as_stubbed_const + + described_class.http_proxy('proxy.example.net', 1987, 'user', 'pass') + expect(request).to have_received(:http_proxy).with('proxy.example.net', 1987, 'user', 'pass') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..eb7db70 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rspec' +require 'webmock/rspec' + +require File.expand_path('../lib/sonarqube', __dir__) + +def capture_output + out = StringIO.new + $stdout = out + $stderr = out + yield + $stdout = STDOUT + $stderr = STDERR + out.string +end + +def load_fixture(name) + name, extension = name.split('.') + File.new(File.dirname(__FILE__) + "/fixtures/#{name}.#{extension || 'json'}") +end + +RSpec.configure do |config| + config.before(:all) do + Sonarqube.endpoint = 'https://api.example.com' + Sonarqube.private_token = 'secret' + end +end + +%i[get post put delete].each do |method| + define_method "stub_#{method}" do |path, fixture, status_code = 200| + stub_request(method, "#{Sonarqube.endpoint}#{path}") + .with(headers: { 'Authorization' => "Basic #{Base64.strict_encode64("#{Sonarqube.private_token}:")}" }) + .to_return(body: load_fixture(fixture), status: status_code) + end + + define_method "a_#{method}" do |path| + a_request(method, "#{Sonarqube.endpoint}#{path}") + .with(headers: { 'Authorization' => "Basic #{Base64.strict_encode64("#{Sonarqube.private_token}:")}" }) + end +end