diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..7581674
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,36 @@
+---
+name: Bug report
+about: Report a bug to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+## Description
+
+
+
+## Steps to reproduce
+
+1.
+2.
+3.
+
+## Expected behavior
+
+
+
+## Actual behavior
+
+
+
+## Environment
+
+- Ruby version:
+- Gem version:
+- OS:
+
+## Additional context
+
+
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..f547360
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Genius API Documentation
+ url: https://github.com/unurgunite/genius-api
+ about: Genius API documentation
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..8220a9f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,24 @@
+---
+name: Feature request
+about: Suggest a new feature or improvement
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+## Problem
+
+
+
+## Proposed solution
+
+
+
+## Alternatives
+
+
+
+## Additional context
+
+
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..782967b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,23 @@
+## Summary
+
+
+
+## Related issues
+
+
+
+## Checklist
+
+- [ ] `rubocop` — 0 offenses
+- [ ] `rspec` — all tests pass
+- [ ] `rbs validate` — OK
+- [ ] `steep check` — no type errors
+- [ ] `docscribe lib` — OK
+
+## Type of change
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Refactoring
+- [ ] Documentation
+- [ ] CI / infrastructure
diff --git a/.github/workflows/check-source-branch.yml b/.github/workflows/check-source-branch.yml
new file mode 100644
index 0000000..1369b96
--- /dev/null
+++ b/.github/workflows/check-source-branch.yml
@@ -0,0 +1,8 @@
+name: Check source branch
+on:
+ pull_request:
+ branches: [master]
+
+jobs:
+ check:
+ uses: FlorexLabs/shared-workflows/.github/workflows/check-source-branch.yml@master
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..42750c9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on: [ push, pull_request ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby: [ '3.2', '3.3', '3.4', '4.0' ]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Ruby ${{ matrix.ruby }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+
+ - name: Install RBS collection
+ run: bundle exec rbs collection install
+
+ - name: RuboCop
+ run: bundle exec rubocop
+
+ - name: RSpec
+ run: bundle exec rspec
+
+ - name: RBS validate
+ run: bundle exec rbs validate
+
+ - name: Docscribe
+ run: bundle exec docscribe lib --rbs-collection
+
+ - name: Steep check
+ run: bundle exec steep check
diff --git a/.gitignore b/.gitignore
index ec19925..472090d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@
rakelib
.env
Rakefile
+.gem_rbs_collection/
+/docs/
diff --git a/.rubocop.yml b/.rubocop.yml
index 8d1fdd3..4cf369c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,13 +1,20 @@
+plugins:
+ - rubocop-performance
+ - rubocop-rspec
+ - rubocop-sorted_methods_by_call
+ - rubocop-rake
+
AllCops:
- TargetRubyVersion: 2.7
+ TargetRubyVersion: 3.2
+ NewCops: enable
-Style/StringLiterals:
- Enabled: true
- EnforcedStyle: double_quotes
+Performance/BindCall:
+ Enabled: false
-Style/StringLiteralsInInterpolation:
- Enabled: true
- EnforcedStyle: double_quotes
+Gemspec/DevelopmentDependencies:
+ EnforcedStyle: gemspec
-Layout/LineLength:
- Max: 120
+RSpec/SpecFilePathFormat:
+ Exclude:
+ - spec/api/*
+ - spec/extensions/*
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..17ce918
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.2.11
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b10f498..4acc888 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,37 @@
## [Unreleased]
-## [0.1.0] - 2021-03-23
+## [0.3.0] - 2026-06-12
-- Initial release
+### Added
+
+- Ruby >= 3.2 requirement
+- RBS type signatures for all API modules and extensions
+- RBS collection via `rbs_collection.yaml`
+- Steep type checker setup
+- `docscribe` gem for automated YARD documentation generation
+- `.docscribe.yml` config with RBS integration (`rbs.enabled: true`, `rbs.collection: true`, `collapse_generics: true`)
+- YARD documentation (100% documented, 0 warnings)
+- CI/CD pipeline with GitHub Actions (rubocop, rspec, docscribe checks)
+- CHANGELOG.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md
+- `bin/release` release script
+
+### Changed
+
+- Updated `actions/checkout` to v4, `ruby/setup-ruby` to v1
+- Updated `dotenv`, `parallel`, `rspec`, `rubocop`, `yard`, `coderay` dependencies
+- Improved YARD annotations with meaningful descriptions and RBS-derived types
+- README.md: updated requirements, added RBS/Steep/docscribe sections
+
+### Fixed
+
+- RuboCop offenses across all source files
+- Anonymous block parameter YARD warning in `errors.rb`
+- Type annotations aligned with RBS signatures
## [0.2.0] - 2022-04-03
- Updated documentation. Added first tests
+
+## [0.1.0] - 2021-03-23
+
+- Initial release
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 51126f5..fc88789 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -2,7 +2,10 @@
## Our Pledge
-We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for
+everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity
+and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion,
+or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
@@ -29,45 +32,62 @@ Examples of unacceptable behavior include:
## Enforcement Responsibilities
-Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take
+appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
-Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits,
+issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for
+moderation decisions when appropriate.
## Scope
-This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
+the community in public spaces. Examples of representing our community include using an official e-mail address, posting
+via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at the [issues page](https://github.com/unurgunite/genius-api/issues). All complaints will be reviewed and investigated promptly and fairly.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible
+for enforcement at the [issues page](https://github.com/unurgunite/genius-api/issues). All complaints will be reviewed
+and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
-Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem
+in violation of this Code of Conduct:
### 1. Correction
-**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the
+community.
-**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation
+and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
-**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including
+unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding
+interactions in community spaces as well as external channels like social media. Violating these terms may lead to a
+temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
-**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified
+period of time. No public or private interaction with the people involved, including unsolicited interaction with those
+enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
-**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate
+behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
@@ -76,9 +96,11 @@ Community leaders will follow these Community Impact Guidelines in determining t
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
-Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
+Community Impact Guidelines were inspired
+by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
-https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
+https://www.contributor-covenant.org/faq. Translations are available
+at https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c1a6c8c..c11b190 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,7 +22,11 @@ This file includes code style references which can help when contributing to pro
3. [Define methods][4.3]
5. [Contributing][5]
1. [Git commits][5.1]
- 2. [Rubocop][5.2]
+ 2. [RuboCop][5.2]
+ 3. [RSpec][5.3]
+ 4. [Docscribe][5.4]
+ 5. [RBS][5.5]
+ 6. [Steep][5.6]
6. [TODO][6]
7. [License][7]
@@ -256,6 +260,51 @@ See: [RuboCop documentation](https://docs.rubocop.org/rubocop/index.html)
Before pushing, you must be sure that the code you write is correct, so it should always be formatted through a static
analyzer. Look towards the `rubocop -D` and `rubocop -A` commands.
+### RSpec
+
+Before pushing, run the test suite to ensure nothing is broken:
+
+```shell
+bundle exec rspec
+```
+
+The test suite currently has 84 examples with 0 failures.
+
+### Docscribe
+
+Docscribe keeps YARD annotations in sync with the codebase. After making changes to method signatures, run:
+
+```shell
+bundle exec docscribe lib
+```
+
+This will check for missing or outdated documentation. To regenerate annotations automatically:
+
+```shell
+bundle exec docscribe -A --rbs-collection lib
+```
+
+The project's docscribe configuration is in `docscribe.yml` at the project root.
+
+### RBS
+
+RBS type signatures are located in `sig/` directory. After adding or changing methods, update the corresponding `.rbs`
+files. Validate signatures with:
+
+```shell
+bundle exec rbs validate
+```
+
+### Steep
+
+Steep performs static type checking using RBS signatures. Run the type checker:
+
+```shell
+bundle exec steep check
+```
+
+All type errors must be resolved before committing.
+
## TODO
- [ ] Look at the documentation with fresh eyes
@@ -306,6 +355,14 @@ the [CC BY-SA 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/)
[5.2]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rubocop
+[5.3]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rspec
+
+[5.4]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#docscribe
+
+[5.5]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rbs
+
+[5.6]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#steep
+
[6]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#todo
[7]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#license
diff --git a/Gemfile b/Gemfile
index c050c07..fc9495f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,23 +1,7 @@
# frozen_string_literal: true
-source "https://rubygems.org"
+source 'https://rubygems.org'
-# Specify your gem's dependencies in genius-api.gemspec
gemspec
-gem "rake", "~> 13.0"
-
-gem "rspec", "~> 3.4"
-
-gem "rubocop", "~> 1.7"
-
-gem "httparty", "~> 0.13.7"
-
-gem "nokogiri"
-
-gem "yard"
-
-group :test do
- gem "coderay"
- gem "dotenv", "~> 2.7.6"
-end
+gem 'parallel', '< 2.0' # 2.0+ requires Ruby >= 3.3
diff --git a/Gemfile.lock b/Gemfile.lock
index 83880e7..d05b811 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,73 +1,174 @@
PATH
remote: .
specs:
- genius-api (0.2.0)
+ genius-api (0.3.0)
+ httparty (~> 0.21)
+ nokogiri
GEM
remote: https://rubygems.org/
specs:
- ast (2.4.2)
+ ast (2.4.3)
+ bigdecimal (4.1.2)
coderay (1.1.3)
- diff-lcs (1.5.0)
+ concurrent-ruby (1.3.6)
+ csv (3.3.5)
+ diff-lcs (1.6.2)
+ docscribe (1.4.1)
+ parser (>= 3.3)
+ prism (~> 1.8)
dotenv (2.7.6)
- httparty (0.13.7)
- json (~> 1.8)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-aarch64-linux-musl)
+ ffi (1.17.4-arm-linux-gnu)
+ ffi (1.17.4-arm-linux-musl)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86_64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
+ fileutils (1.8.0)
+ httparty (0.24.2)
+ csv
+ mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
- json (1.8.6)
- multi_xml (0.6.0)
- nokogiri (1.13.9-x86_64-linux)
+ json (2.19.9)
+ language_server-protocol (3.17.0.5)
+ lint_roller (1.1.0)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ mini_mime (1.1.5)
+ multi_xml (0.9.1)
+ bigdecimal (>= 3.1, < 5)
+ nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
- parallel (1.22.1)
- parser (3.1.2.1)
+ nokogiri (1.19.3-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.3-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.3-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.3-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.3-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.3-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.3-x86_64-linux-musl)
+ racc (~> 1.4)
+ parallel (1.28.0)
+ parser (3.3.11.1)
ast (~> 2.4.1)
- racc (1.6.0)
+ racc
+ prism (1.9.0)
+ racc (1.8.1)
rainbow (3.1.1)
- rake (13.0.6)
- regexp_parser (2.6.1)
- rexml (3.2.5)
- rspec (3.12.0)
- rspec-core (~> 3.12.0)
- rspec-expectations (~> 3.12.0)
- rspec-mocks (~> 3.12.0)
- rspec-core (3.12.0)
- rspec-support (~> 3.12.0)
- rspec-expectations (3.12.0)
+ rake (13.4.2)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rbs (4.0.2)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ regexp_parser (2.12.0)
+ rspec (3.13.2)
+ rspec-core (~> 3.13.0)
+ rspec-expectations (~> 3.13.0)
+ rspec-mocks (~> 3.13.0)
+ rspec-core (3.13.6)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.12.0)
- rspec-mocks (3.12.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.12.0)
- rspec-support (3.12.0)
- rubocop (1.31.0)
- parallel (~> 1.10)
- parser (>= 3.1.0.0)
+ rspec-support (~> 3.13.0)
+ rspec-support (3.13.7)
+ rubocop (1.87.0)
+ json (~> 2.3)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.1.0)
+ parallel (>= 1.10)
+ parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 1.8, < 3.0)
- rexml (>= 3.2.5, < 4.0)
- rubocop-ast (>= 1.18.0, < 2.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
- unicode-display_width (>= 1.4.0, < 3.0)
- rubocop-ast (1.23.0)
- parser (>= 3.1.1.0)
- ruby-progressbar (1.11.0)
- unicode-display_width (2.3.0)
- webrick (1.7.0)
- yard (0.9.28)
- webrick (~> 1.7.0)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.49.1)
+ parser (>= 3.3.7.2)
+ prism (~> 1.7)
+ rubocop-performance (1.26.1)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.47.1, < 2.0)
+ rubocop-rake (0.7.1)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.72.1)
+ rubocop-rspec (3.10.2)
+ lint_roller (~> 1.1)
+ regexp_parser (>= 2.0)
+ rubocop (~> 1.86, >= 1.86.2)
+ rubocop-sorted_methods_by_call (1.2.3)
+ lint_roller
+ rubocop (>= 1.72.0)
+ ruby-progressbar (1.13.0)
+ securerandom (0.4.1)
+ steep (2.0.0)
+ concurrent-ruby (>= 1.1.10)
+ csv (>= 3.0.9)
+ fileutils (>= 1.1.0)
+ json (>= 2.1.0)
+ language_server-protocol (>= 3.17.0.4, < 4.0)
+ listen (~> 3.0)
+ logger (>= 1.3.0)
+ parser (>= 3.2)
+ prism (>= 0.25.0)
+ rainbow (>= 2.2.2, < 4.0)
+ rbs (~> 4.0)
+ securerandom (>= 0.1)
+ strscan (>= 1.0.0)
+ terminal-table (>= 2, < 5)
+ uri (>= 0.12.0)
+ strscan (3.1.8)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ tsort (0.2.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
+ yard (0.9.44)
PLATFORMS
- x86_64-linux
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
DEPENDENCIES
coderay
+ docscribe
dotenv (~> 2.7.6)
genius-api!
- httparty (~> 0.13.7)
- nokogiri
+ parallel (< 2.0)
rake (~> 13.0)
+ rbs
rspec (~> 3.4)
- rubocop (~> 1.7)
+ rubocop
+ rubocop-performance
+ rubocop-rake
+ rubocop-rspec
+ rubocop-sorted_methods_by_call
+ steep
yard
BUNDLED WITH
- 2.3.6
+ 4.0.14
diff --git a/README.md b/README.md
index 7e33162..41efd6e 100644
--- a/README.md
+++ b/README.md
@@ -14,15 +14,21 @@ API and much more!
2. [Automatic installation][2.1.2]
2. [Build via bundler][2.2]
3. [Usage][3]
-4. [Todo][4]
+4. [Requirements][4]
+ 1. [Common usage][4.1]
+ 2. [Development purposes][4.2]
5. [Development][5]
-6. [Requirements][6]
- 1. [Common usage][6.1]
- 2. [Development purposes][6.2]
-7. [Project style guide][7]
-8. [Contributing][8]
-9. [License][9]
-10. [Code of Conduct][10]
+6. [Type checking][6]
+ 1. [RBS][6.1]
+ 2. [Steep][6.2]
+7. [Documentation][7]
+ 1. [YARD][7.1]
+ 2. [Docscribe][7.2]
+8. [Project style guide][8]
+9. [Contributing][9]
+10. [License][10]
+11. [Code of Conduct][11]
+12. [TODO][12]
## Overview
@@ -91,23 +97,6 @@ gem install genius-api
All docs are available at the separate page: https://unurgunite.github.io/genius-api_docs/
-## TODO
-
-- [x] Update `README.md`
-- [ ] Refactor code base
-- [ ] Add tests with RSpec
-- [ ] 100% code coverage with RuboCop
-- [ ] Refactor code according to the style guides
-
-## Development
-
-After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can
-also run `bin/console` for an interactive prompt that will allow you to experiment.
-
-To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
-version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
-push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
-
## Requirements
This section will show dependencies which are used in the project. This section splits in two other sections —
@@ -115,7 +104,7 @@ requirements for common use and requirements for the development purposes.
### Common use
-The `genius-api` gem is built on top of two other gems:
+The `genius-api` gem requires Ruby >= 3.2 and is built on top of two other gems:
| Dependencies | Description |
|-----------------|---------------------------------------------------------------------------------------------|
@@ -126,14 +115,74 @@ The `genius-api` gem is built on top of two other gems:
For the development purposes `genius-api` gem uses:
-| Dependencies | Description |
-|----------------|------------------------------------------------------------------------------------------|
-| [RSpec][201] | The RSpec gem is used for test which are located in a separate folder under `spec` name. |
-| [RuboCop][202] | The RuboCop gem is used for code formatting. |
-| [Rake][203] | The Rake gem is used for building tasks as generating documentation. |
-| [Dotenv][204] | The Dotenv gem is used for setting variables for test environment (`token`, for e.g.). |
-| [Coderay][205] | The Coderay gem is used for colorizing Rspec output. |
-| [YARD][206] | The YARD gem is used for the documentation. |
+| Dependencies | Description |
+|-----------------|------------------------------------------------------------------------------------------|
+| [RSpec][201] | The RSpec gem is used for test which are located in a separate folder under `spec` name. |
+| [RuboCop][202] | The RuboCop gem is used for code formatting. |
+| [Rake][203] | The Rake gem is used for building tasks as generating documentation. |
+| [Dotenv][204] | The Dotenv gem is used for setting variables for test environment (`token`, for e.g.). |
+| [Coderay][205] | The Coderay gem is used for colorizing Rspec output. |
+| [YARD][206] | The YARD gem is used for the documentation. |
+| [RBS][207] | The RBS gem is used for Ruby type signatures. |
+| [Steep][208] | The Steep gem is used for static type checking. |
+| [Docscribe][209]| The Docscribe gem is used for automated YARD documentation generation. |
+
+## Development
+
+After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can
+also run `bin/console` for an interactive prompt that will allow you to experiment.
+
+To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
+version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
+push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
+
+## Type checking
+
+This project uses RBS for type signatures and Steep for static type checking.
+
+### RBS
+
+RBS type signatures are located in `sig/` directory. To validate them:
+
+```shell
+bundle exec rbs validate
+```
+
+### Steep
+
+To run the Steep type checker:
+
+```shell
+bundle exec steep check
+```
+
+## Documentation
+
+This project uses YARD for documentation with Docscribe for automated annotation generation.
+
+### YARD
+
+To generate YARD documentation:
+
+```shell
+bundle exec yard doc -o docs
+```
+
+Generated docs will be placed in `docs/` (gitignored).
+
+### Docscribe
+
+Docscribe keeps YARD annotations in sync with the codebase. To check for missing or outdated docs:
+
+```shell
+bundle exec docscribe lib
+```
+
+To regenerate annotations using RBS type information:
+
+```shell
+bundle exec docscribe -A --rbs-collection lib
+```
## Project style guide
@@ -158,11 +207,6 @@ git push origin refactor
And then make new pull request with additional notes of what you have done. The better the changes are scheduled, the
faster the PR will be checked.
-## Code of Conduct
-
-Everyone interacting in the `Genius::Api` project's codebases, issue trackers, chat rooms and mailing lists is expected
-to follow the [code of conduct](https://github.com/unurgunite/genius-api/blob/master/CODE_OF_CONDUCT.md).
-
## License
The gem is available as open source under the terms of the [GPLv3 License](https://opensource.org/licenses/GPL-3.0). The
@@ -179,6 +223,12 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause)


+## TODO
+
+- [ ] Refactor code base
+- [ ] 100% code coverage with RuboCop
+- [ ] Refactor code according to the style guides
+
[1]:https://github.com/unurgunite/genius-api#overview
[2]:https://github.com/unurgunite/genius-api#installation
@@ -193,23 +243,35 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause)
[3]:https://github.com/unurgunite/genius-api#usage
-[4]:https://github.com/unurgunite/genius-api#todo
+[4]:https://github.com/unurgunite/genius-api#requirements
+
+[4.1]:https://github.com/unurgunite/genius-api#common-usage
+
+[4.2]:https://github.com/unurgunite/genius-api#development-purposes
[5]:https://github.com/unurgunite/genius-api#development
-[6]:https://github.com/unurgunite/genius-api#requirements
+[6]:https://github.com/unurgunite/genius-api#type-checking
+
+[6.1]:https://github.com/unurgunite/genius-api#rbs
+
+[6.2]:https://github.com/unurgunite/genius-api#steep
+
+[7]:https://github.com/unurgunite/genius-api#documentation
-[6.1]:https://github.com/unurgunite/genius-api#common-usage
+[7.1]:https://github.com/unurgunite/genius-api#yard
-[6.2]:https://github.com/unurgunite/genius-api#development-purposes
+[7.2]:https://github.com/unurgunite/genius-api#docscribe
-[7]:https://github.com/unurgunite/genius-api#project-style-guide
+[8]:https://github.com/unurgunite/genius-api#project-style-guide
-[8]:https://github.com/unurgunite/genius-api#contributing
+[9]:https://github.com/unurgunite/genius-api#contributing
-[9]:https://github.com/unurgunite/genius-api#license
+[10]:https://github.com/unurgunite/genius-api#license
-[10]:https://github.com/unurgunite/genius-api#code-of-conduct
+[11]:https://github.com/unurgunite/genius-api#code-of-conduct
+
+[12]:https://github.com/unurgunite/genius-api#todo
[101]:https://rubygems.org/gems/httparty
@@ -226,3 +288,9 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause)
[205]:https://rubygems.org/gems/coderay
[206]:https://rubygems.org/gems/yard
+
+[207]:https://rubygems.org/gems/rbs
+
+[208]:https://rubygems.org/gems/steep
+
+[209]:https://rubygems.org/gems/docscribe
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..60b4ea2
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+|---------|--------------------|
+| 0.3.x | :white_check_mark: |
+| < 0.3.0 | :x: |
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability, please do **not** open a public issue. Instead, send a private email to the maintainers at the address listed in the gem specification.
+
+We will acknowledge receipt within 48 hours and provide an estimated timeline for a fix.
diff --git a/Steepfile b/Steepfile
new file mode 100644
index 0000000..ee037f0
--- /dev/null
+++ b/Steepfile
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+D = Steep::Diagnostic
+
+target :lib do
+ signature 'sig'
+ check 'lib'
+ ignore 'lib/extensions/extensions.rb'
+
+ configure_code_diagnostics do |config|
+ config[D::Ruby::NoMethod] = :information
+ end
+end
diff --git a/bin/console b/bin/console
index 8390d65..c3d265b 100755
--- a/bin/console
+++ b/bin/console
@@ -1,8 +1,8 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
-require "bundler/setup"
-require "genius/api"
+require 'bundler/setup'
+require 'genius/api'
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
@@ -11,5 +11,5 @@ require "genius/api"
# require "pry"
# Pry.start
-require "irb"
+require 'irb'
IRB.start(__FILE__)
diff --git a/bin/release b/bin/release
new file mode 100755
index 0000000..2e1da4b
--- /dev/null
+++ b/bin/release
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+bash <(curl -sSL https://raw.githubusercontent.com/unurgunite/release_gem/refs/heads/master/release)
diff --git a/docscribe.yml b/docscribe.yml
new file mode 100644
index 0000000..1ef2d24
--- /dev/null
+++ b/docscribe.yml
@@ -0,0 +1,9 @@
+---
+rbs:
+ enabled: true
+ collection: true
+ collapse_generics: true
+
+filter:
+ exclude:
+ - Genius::Errors::DynamicRescue.rescue_from
diff --git a/exe/genius-api b/exe/genius-api
index c7b203e..da98875 100755
--- a/exe/genius-api
+++ b/exe/genius-api
@@ -1,3 +1,4 @@
#!/usr/bin/env ruby
+# frozen_string_literal: true
-require "genius/api"
+require 'genius/api'
diff --git a/genius-api.gemspec b/genius-api.gemspec
index c06d772..2e4cbda 100644
--- a/genius-api.gemspec
+++ b/genius-api.gemspec
@@ -1,41 +1,48 @@
# frozen_string_literal: true
-require_relative "lib/genius/api/version"
+require_relative 'lib/genius/api/version'
Gem::Specification.new do |spec|
- spec.name = "genius-api"
+ spec.name = 'genius-api'
spec.version = Genius::Api::VERSION
- spec.authors = ["unurgunite"]
- spec.email = ["noreply@example.com"]
+ spec.authors = ['unurgunite']
+
+ spec.summary = 'Library to work with Genius API'
+ spec.description = 'Library to work with Genius API, written in Ruby'
+ spec.homepage = 'https://github.com/unurgunite/genius-api'
+ spec.license = 'GPL-3.0'
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.2')
+
+ spec.metadata = {
+ 'allowed_push_host' => 'https://example.com',
+ 'homepage_uri' => spec.homepage,
+ 'source_code_uri' => 'https://github.com/unurgunite/genius-api',
+ 'changelog_uri' => 'https://github.com/unurgunite/genius-api/blob/master/CHANGELOG.md',
+ 'rubygems_mfa_required' => 'true'
+ }
- spec.summary = "Library to work with Genius API"
- spec.description = "Library to work with Genius API, written in Ruby"
- spec.homepage = "https://github.com/unurgunite/genius-api"
- spec.license = "GPL-3.0"
- spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
-
- spec.metadata["allowed_push_host"] = "https://example.com"
-
- spec.metadata["homepage_uri"] = spec.homepage
- spec.metadata["source_code_uri"] = "https://github.com/unurgunite/genius-api"
- spec.metadata["changelog_uri"] = "https://github.com/unurgunite/genius-api/blob/master/CHANGELOG.md"
-
- # Specify which files should be added to the gem when it is released.
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(File.expand_path(__dir__)) do
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
end
- spec.bindir = "exe"
+ spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
- spec.require_paths = ["lib"]
+ spec.require_paths = ['lib']
spec.post_install_message = "Thanks for installing!\nA Ruby gem for scraping with Genius API🤓"
- # Uncomment to register a new dependency of your gem
- # spec.add_dependency "example-gem", "~> 1.0"
- spec.add_development_dependency "httparty", "~> 0.13.7"
- spec.add_development_dependency "nokogiri"
-
- # For more information and examples about making a new gem, checkout our
- # guide at: https://bundler.io/guides/creating_gem.html
+ spec.add_dependency 'httparty', '~> 0.21'
+ spec.add_dependency 'nokogiri'
+ spec.add_development_dependency 'coderay'
+ spec.add_development_dependency 'docscribe'
+ spec.add_development_dependency 'dotenv', '~> 2.7.6'
+ spec.add_development_dependency 'rake', '~> 13.0'
+ spec.add_development_dependency 'rbs'
+ spec.add_development_dependency 'rspec', '~> 3.4'
+ spec.add_development_dependency 'rubocop'
+ spec.add_development_dependency 'rubocop-performance'
+ spec.add_development_dependency 'rubocop-rake'
+ spec.add_development_dependency 'rubocop-rspec'
+ spec.add_development_dependency 'rubocop-sorted_methods_by_call'
+ spec.add_development_dependency 'steep'
+ spec.add_development_dependency 'yard'
end
diff --git a/lib/extensions/deep_find.rb b/lib/extensions/deep_find.rb
index 3c4dfd0..b48c44e 100644
--- a/lib/extensions/deep_find.rb
+++ b/lib/extensions/deep_find.rb
@@ -1,42 +1,36 @@
# frozen_string_literal: true
class Hash # :nodoc:
- # +Hash#deep_find+ -> value
+ # Searches for a key in nested hashes and arrays. Returns matching values or +nil+.
#
- # @param [Object] key A key, which value should be found
- # @param [FalseClass] uniq A flag to make values unique in an array
- # @return [Object] output depends on key value
- # This method is an extension for Hash core class to search for a value of a key in N-nested
- # hash. It provides search for multiple values if key appears more than once. For e.g.:
- #
- # @example
- # musicians = { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] },
- # "Adele" => { "19" => ["Day Dreamer", "Best for Last"] },
- # "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } }
- # musicians.deep_find("19") #=> ["Day Dreamer", "Best for Last"]
- # musicians.deep_find("Adele") #=> {"19"=>["Day Dreamer", "Best for Last"]}
- # musicians.deep_find("28") #=> [["Highest in the Room", "Franchise"], ["Shape of You", "Castle on the Hill"]]
- #
- # If values are identical, they will be returned in a single copy. You can disable this
- # feature with special param +uniq+, which is +true+ by default. For e.g.:
- #
- # @example
- # h = {"a" => "b", "c" => {"a" => "b"}}
- # h.deep_find("a") #=> "b", instead ["b", "b"]
- # @todo change uniq true to uniq false
+ # @param [Object] key Key to search for.
+ # @param [Boolean] uniq If +true+, deduplicates results.
+ # @return [nil, Object]
def deep_find(key, uniq: true)
- result = []
- result << self[key]
- each_value do |hash_value|
- values = hash_value.is_a?(Array) ? hash_value : [hash_value]
- values.each do |value|
- result << value.deep_find(key) if value.is_a? Hash
- end
- end
- result = result.compact.delete_if do |i|
- i.is_a?(Array) && i.empty?
- end
+ result = collect_values(key)
+ result.compact!
+ result.delete_if { |i| i.is_a?(Array) && i.empty? }
result.uniq! if uniq
+ return nil if result.empty?
+
result.size == 1 ? result.first : result
end
+
+ private
+
+ # Recursively collects values for a key from nested hashes.
+ #
+ # @private
+ # @param [Object] key Key to search for.
+ # @return [Array]
+ def collect_values(key)
+ result = [self[key]]
+ each_value do |value|
+ values = value.is_a?(Array) ? value : [value]
+ values.each do |v|
+ result << v.deep_find(key) if v.is_a?(Hash)
+ end
+ end
+ result
+ end
end
diff --git a/lib/extensions/extensions.rb b/lib/extensions/extensions.rb
index 402940b..ac0ae90 100644
--- a/lib/extensions/extensions.rb
+++ b/lib/extensions/extensions.rb
@@ -2,11 +2,11 @@
# This file is an extension initializer. That means that it includes extension files
# during initialization and extension methods are visible from everywhere
-require_relative "deep_find"
-require_relative "unescape"
-require_relative "token_ext"
-require_relative "options_helper"
+require_relative 'deep_find'
+require_relative 'unescape'
+require_relative 'token_ext'
+require_relative 'options_helper'
-require "json"
-require "nokogiri"
-require "httparty"
+require 'json'
+require 'nokogiri'
+require 'httparty'
diff --git a/lib/extensions/options_helper.rb b/lib/extensions/options_helper.rb
index 4828693..dc3c3b2 100644
--- a/lib/extensions/options_helper.rb
+++ b/lib/extensions/options_helper.rb
@@ -7,9 +7,9 @@ class Object # :nodoc:
# @param [Array] arry Array of possible params for response.
# @return [String]
def options_helper(options, arry)
- params = ""
+ params = ''
opt = arry
- options.each_key do |k, v|
+ options.each do |k, v|
params.insert(params.length, "{k}=#{v}") if opt.include? k
end
params
diff --git a/lib/extensions/token_ext.rb b/lib/extensions/token_ext.rb
index 2bae846..90afafc 100644
--- a/lib/extensions/token_ext.rb
+++ b/lib/extensions/token_ext.rb
@@ -4,7 +4,7 @@ class Object # :nodoc:
# +Object#token_ext+ -> String
#
# Helper method to check if token is correct
- # @param [String] token Token to access https://api.genius.com.
+ # @param [String?] token Token to access https://api.genius.com.
# @return [String]
def token_ext(token)
token || Genius::Auth.instance_variable_get(:@token)
diff --git a/lib/extensions/unescape.rb b/lib/extensions/unescape.rb
index f32dbe9..63df72b 100644
--- a/lib/extensions/unescape.rb
+++ b/lib/extensions/unescape.rb
@@ -5,9 +5,9 @@ class String # :nodoc:
#
# String#unescape method unescapes input JSON strings.
#
- # @return [String (frozen)]
+ # @return [String]
def unescape
- string = gsub(/(? true or false
- #
- # @param [Object] id Identification of annotations resource.
- # @param [Object] action Action to do during PUT request. Possible actions: nil, upvote, downvote, unvote.
- # @param [String] token Token to access https://api.genius.com.
- # @param [String (frozen)] http_verb HTTP verb for request. Possible verbs: get, post, put, delete.
- # @param [Hash] options Options for PUT response.
- # @option options [String] :markdown The text for the note, in
- # {markdown}[https://help.github.com/articles/github-flavored-markdown/]
- # @option options [String] :raw_annotatable_url The original URL of the page.
- # @option options [String] :fragment The highlighted fragment.
- # @option options [String] :before_html The HTML before the highlighted fragment (prefer up to 200 characters).
- # @option options [String] :after_html The HTML after the highlighted fragment (prefer up to 200 characters).
- # @option options [String] :canonical_url The href property of the tag
- # on the page. Including it will help make sure newly created annotation appear on the correct page.
- # @option options [String] :og_url The content property of the tag on
- # the page. Including it will help make sure newly created annotation appear on the correct page.
- # @option options [String] :title The title of the page.
- # @raise [ArgumentError] if +action+ got incorrect value.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [NilClass] if TokenError exceptions raised.
- #
- # +GET /annotations/:id+
- # Data for a specific annotation.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840)
- #
- # +POST /annotations+
- # Requires scope: _create_annotation_
- #
- # Creates a new annotation on a public web page. The returned value will be the new annotation object, in the
- # same form as would be returned by GET /annotation/:id with the new annotation's ID. Requires JSON payload.
- # @example Example Payload
- # {
- # "annotation": {
- # "body": {
- # "markdown": "hello **world!**"
- # }
- # },
- # "referent": {
- # "raw_annotatable_url": "http://seejohncode.com/2014/01/27/vim-commands-piping/",
- # "fragment": "execute commands",
- # "context_for_display": {
- # "before_html": "You may know that you can ",
- # "after_html": " from inside of vim, with a vim command:"
- # }
- # },
- # "web_page": {
- # "canonical_url": null,
- # "og_url": null,
- # "title": "Secret of Mana"
- # }
- # }
- #
- # Example usage
- # Genius::Annotations.annotation(id:, http_verb: "post", markdown: "Foo **Bar**",
- # raw_annotatable_url: "https://example.com")
- # will reproduce JSON object via +Genius::Annotations.post_payload+ method. According to last example it will
- # return
- # {
- # annotation: {
- # body: {
- # markdown: "Foo **Bar**"
- # }
- # },
- # referent: {
- # raw_annotatable_url: "https://example.com",
- # fragment: null,
- # context_for_display: {
- # before_html: null,
- # after_html: null
- # }
- # },
- # web_page: {
- # canonical_url: null,
- # og_url: null,
- # title: null
- # }
- # }
- #
- # There is a full list of possible params:
- # * annotation
- # * body
- # * markdown - The text for the note, in
- # [markdown](https://help.github.com/articles/github-flavored-markdown/) _(Required)_
- # * referent
- # * raw_annotatable_url - The original URL of the page _(Required)_
- #
- # * fragment - The highlighted fragment _(Required)_
- # * context_for_display
- #
- # * before_html - The HTML before the highlighted fragment (prefer up to 200 characters)
- #
- # * after_html - The HTML after the highlighted fragment (prefer up to 200 characters)
- # * web_page At least one required
- #
- # * canonical_url - The href property of the ++ tag on the page. Including it will
- # help make sure newly created annotation appear on the correct page
- #
- # * og_url - The content property of the tag on the page. Including it will help make sure newly created
- # annotation appear on the correct page
- #
- # * title - The title of the page
- #
- # +PUT /annotations/:id+
- # Requires scope: _manage_annotation_
- # Updates an annotation created by the authenticated user. Accepts the same parameters as POST /annotation above.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840, http_verb: "put")
- #
- # +DELETE /annotations/:id+
- # Requires scope: _manage_annotation_
- # Deletes an annotation created by the authenticated user.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840, http_verb: "delete")
- #
- # +PUT /annotations/:id/upvote+
- # Requires scope: _vote_
- # Votes positively for the annotation on behalf of the authenticated user.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "upvote")
- #
- # +PUT /annotations/:id/downvote+
- # Requires scope: _vote_
- # Votes negatively for the annotation on behalf of the authenticated user.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "vote")
- #
- # +PUT /annotations/:id/unvote+
- # Requires scope: _vote_
- # Removes the authenticated user's vote (up or down) for the annotation.
- #
- # @example Example usage
- # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "vote")
- def annotations(id:, action:, token:, http_verb: "get", options: {})
+ # Data for a specific annotation. Supports GET, POST, PUT, DELETE verbs with optional voting actions.
+ #
+ # @param [Integer] id ID of the annotation.
+ # @param [String?] action Action for PUT request: +nil+, +upvote+, +downvote+, or +unvote+.
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [String] http_verb HTTP verb: +get+, +post+, +put+, +delete+.
+ # @param [Hash] options Options for POST/PUT payload.
+ # @raise [ArgumentError] if +action+ is set for non-PUT request.
+ # @return [Hash, nil]
+ def annotations(id:, action:, token:, http_verb: 'get', options: {})
return if token.nil? && !Auth.authorized?.nil?
Errors.validate_token(token) unless token.nil?
- raise ArgumentError, "only PUT accepts `action` param" if http_verb != "put" && !action.nil?
+ raise ArgumentError, 'only PUT accepts `action` param' if http_verb != 'put' && !action.nil?
JSON.parse(request(id: id, action: action, token: token, http_verb: http_verb, options: options).body)
end
private
- # +Genius::Annotations.request+ -> HTTParty::Response
+ # Sends an HTTP request based on the verb and returns the raw response.
#
# @private
- # @see .annotations
+ # @param [Integer] id ID of the annotation.
+ # @param [String?] action Action for PUT request.
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [String] http_verb HTTP verb.
+ # @param [Hash] options Options for POST/PUT payload.
+ # @raise [ArgumentError] if HTTP verb is invalid.
+ # @return [HTTParty::Response]
def request(id:, action:, token:, http_verb:, options:)
case http_verb
- when "get"
- HTTParty.get("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}")
- when "post"
- HTTParty.post("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}",
- body: post_payload(options: options))
- when "put"
- put_request(id: id, action: action, token: token, options: options)
- when "delete"
- HTTParty.delete("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}")
- else
- raise ArgumentError, "Something bad happened..."
+ when 'get' then HTTParty.get("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}")
+ when 'post' then HTTParty.post("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}",
+ body: post_payload(options: options))
+ when 'put' then put_request(id: id, action: action, token: token, options: options)
+ when 'delete' then HTTParty.delete("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}")
+ else raise ArgumentError, 'Something bad happened...'
end
end
- # +Genius::Annotations.put_request+ -> HTTParty::Response
+ # Sends a PUT request with optional voting action.
#
# @private
- # @see .annotations
+ # @param [Integer] id ID of the annotation.
+ # @param [String?] action Action: +nil+, +upvote+, +downvote+, or +unvote+.
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Hash] options Options for PUT payload.
+ # @raise [ArgumentError] if +action+ is invalid.
+ # @return [HTTParty::Response]
def put_request(id:, action:, token:, options:)
case action
- when nil
- HTTParty.put("#{Api::RESOURCE}/annotations/#{id}/#{action}?access_token=#{token_ext(token)}",
- body: post_payload(options: options))
- when "upvote", "downvote", "unvote"
- HTTParty.put("#{Api::RESOURCE}/annotations/#{id}/#{action}?access_token=#{token_ext(token)}")
+ when nil then HTTParty.put("#{Api::RESOURCE}/annotations/#{id}/#{action}?access_token=#{token_ext(token)}",
+ body: post_payload(options: options))
+ when 'upvote', 'downvote', 'unvote' then HTTParty.put("#{Api::RESOURCE}/annotations/#{id}/#{action}?access_token=#{token_ext(token)}")
else
actions = %w[upvote downvote unvote]
raise ArgumentError,
- "Invalid value for `action` param. Allowed values are: #{actions.join(", ")}"
+ "Invalid value for `action` param. Allowed values are: #{actions.join(', ')}"
end
end
- # +Genius::Annotations.post_payload+ -> String
+ # Builds a JSON payload for POST and PUT requests from options.
#
# @private
- # @param [Hash] options Options for PUT response.
- # @option options [String] :markdown The text for the note, in
- # {markdown}[https://help.github.com/articles/github-flavored-markdown/].
- # @option options [String] :raw_annotatable_url The original URL of the page.
- # @option options [String] :fragment The highlighted fragment.
- # @option options [String] :before_html The HTML before the highlighted fragment (prefer up to 200 characters).
- # @option options [String] :after_html The HTML after the highlighted fragment (prefer up to 200 characters).
- # @option options [String] :canonical_url The href property of the tag
- # on the page. Including it will help make sure newly created annotation appear on the correct page.
- # @option options [String] :og_url The content property of the tag on
- # the page. Including it will help make sure newly created annotation appear on the correct page.
- # @option options [String] :title The title of the page.
+ # @param [Hash] options Options containing +:markdown+, +:raw_annotatable_url+, +:fragment+, etc.
# @return [String]
def post_payload(options: {})
{
- annotation: {
- body: {
- markdown: options[:markdown]
- }
- },
+ annotation: { body: { markdown: options[:markdown] } },
referent: {
raw_annotatable_url: options[:raw_annotatable_url],
fragment: options[:fragment],
- context_for_display: {
- before_html: options[:before_html],
- after_html: options[:after_html]
- }
+ context_for_display: { before_html: options[:before_html], after_html: options[:after_html] }
},
- web_page: {
- canonical_url: options[:canonical_url],
- og_url: options[:og_url],
- title: options[:title]
- }
+ web_page: { canonical_url: options[:canonical_url], og_url: options[:og_url], title: options[:title] }
}.to_json
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/artists.rb b/lib/genius/api/artists.rb
index 15fc510..d1e00f9 100644
--- a/lib/genius/api/artists.rb
+++ b/lib/genius/api/artists.rb
@@ -5,16 +5,12 @@ module Genius
# usually a musician or group of musicians.
module Artists
class << self
- # +Genius::Artists.artists+ -> Hash
- #
# Data for a specific artist.
#
- # @param [String] token Token to access https://api.genius.com.
- # @param [String] id ID of the song.
- # @raise [ArgumentError] if +id+ is +nil+.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [Hash]
- # @return [NilClass] if TokenError exception raised.
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Integer?] id ID of the artist.
+ # @raise [ArgumentError] if +id+ is nil.
+ # @return [Hash, nil]
def artists(token: nil, id: nil)
Auth.authorized?(method_name: "#{Module.nesting[1].name}.#{__method__}") if token.nil?
Errors.validate_token(token) unless token.nil?
@@ -24,21 +20,12 @@ def artists(token: nil, id: nil)
JSON.parse(response)
end
- # +Genius::Artists.artists_songs+ -> Hash | NilClass
- #
- # Documents (songs) for the artist specified. By default, 20 items are returned for each request.
+ # Songs for the artist specified. By default 20 items per request.
#
- # @param [String] token Token to access https://api.genius.com.
- # @param [String] id ID of the song.
- # @param [Hash] options
- # @option options [Integer] :per_page Number of results to return per request.
- # @option options [Integer] :page Paginated offset, (e.g., +per_page=5&page=3+ returns songs 11-15).
- # @option options [String] :sort +title+ (default) or +popularity+.
- # @raise [ArgumentError] if +sort+ got incorrect value.
- # @raise [ArgumentError] if +per_page+ or +page+ are negative.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [Hash]
- # @return [NilClass] if TokenError exception raised.
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Integer?] id ID of the artist.
+ # @param [Hash] options Optional query params: +:sort+, +:per_page+, +:page+.
+ # @return [Hash, nil]
def artists_songs(token: nil, id: nil, options: {})
return if token.nil? && !Auth.authorized?.nil?
@@ -54,47 +41,42 @@ def artists_songs(token: nil, id: nil, options: {})
private
- # +Genius::Artists.validate+ -> value
- #
- # A helper method which validates some options for artists endpoint.
+ # Validates sort, per_page, and page options for artists endpoint.
#
- # @param [Array] sort_values
- # @param [Hash] options
- # @return [NilClass]
+ # @private
+ # @param [Array] sort_values Allowed sort values.
+ # @param [Object] options Options with +:sort+, +:per_page+, +:page+.
+ # @return [void]
def validate(sort_values, **options)
validate_sort(options[:sort], sort_values)
validate_page_per_page(options[:per_page])
validate_page_per_page(options[:page])
end
- # +Genius::Artists.validate_sort+ -> value
+ # Validates the sort option against allowed values.
#
- # A helper method which validates sort options for artists endpoint.
- #
- # @see Artists.artists_songs
- # @param [String] sort
- # @param [Array] sort_values Possible values for sort.
- # @raise [ArgumentError] if sort is invalid value.
- # @return [Object]
+ # @private
+ # @param [String?] sort Sort value to validate.
+ # @param [Array] sort_values Allowed sort values.
+ # @raise [ArgumentError] if +sort+ is not in +sort_values+.
+ # @return [void]
def validate_sort(sort, sort_values)
return unless sort && !sort_values.include?(sort)
- raise ArgumentError, "`sort` can't be #{sort}. Possible values: #{sort_values.join(", ")}."
+ raise ArgumentError, "`sort` can't be #{sort}. Possible values: #{sort_values.join(', ')}."
end
- # +Genius::Artists.validate_page_per_page+ -> value
- #
- # A helper method which validates per_page or page option for artists endpoint.
+ # Validates that per_page or page is not negative.
#
- # @see Artists.artists_songs
- # @param [Integer] page_per_page
- # @raise [ArgumentError] if per_page or page does not exist or negative.
- # @return [NilClass]
+ # @private
+ # @param [Integer?] page_per_page Value to validate.
+ # @raise [ArgumentError] if value is negative.
+ # @return [void]
def validate_page_per_page(page_per_page)
raise ArgumentError, "`per_page` or `page` can't be negative." if page_per_page&.negative?
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/authorization.rb b/lib/genius/api/authorization.rb
index 17e1090..6da7186 100644
--- a/lib/genius/api/authorization.rb
+++ b/lib/genius/api/authorization.rb
@@ -8,31 +8,22 @@ module Genius
# Genius::Auth.login="yuiaYqbncErCVwItjQxFspNWUZLhGpXrPbkvgbgHSEKJRAlToamzMfdOeDB"
module Auth
class << self
- # +Genius::Auth.token=+ -> true or false
- #
- # +Genius::Auth.token=+ is a setter which handles all possible exceptions
- # under the hood during authentication. It means that you should never use
- # +token=+ method unless you actually know that your credentials are
- # valid (not recommended).
+ # Sets the authentication token after validation.
#
# @param [String] token Token to access https://api.genius.com.
- # @raise [TokenError] if +token+ is invalid.
+ # @raise [Genius::Errors::TokenError] if +token+ is invalid.
# @return [String]
- # @see .authorized?
def token=(token)
Genius::Errors.validate_token(token)
@token = token
end
- # +Genius::Auth.authorized?+ -> true or false
- #
- # +authorized?+ method checks if user in current session is authorized.
+ # Checks if the current token is authorized. Returns +false+ on validation failure.
#
- # @param [NilClass|String] method_name Optional param to pass method name
- # where exception was raised.
- # @raise [TokenError] if +token+ is invalid.
+ # @param [String] token Token to validate.
+ # @param [String] method_name Method name for error messages.
+ # @raise [Genius::Errors::TokenError]
# @return [Boolean]
- # @todo somehow detect exceptions as boolean type
def authorized?(token = @token, method_name: "#{Module.nesting[1].name}.#{__method__}")
Errors.validate_token(token, method_name: method_name)
rescue Genius::Errors::TokenError
@@ -41,19 +32,16 @@ def authorized?(token = @token, method_name: "#{Module.nesting[1].name}.#{__meth
true
end
- # +Genius::Auth.logout!+ -> NilClass
- #
- # +logout!+ method modifies a +token+ object and revoke session by
- # setting +nil+ to the +token+.
+ # Revokes the current session by setting the token to +nil+.
#
- # @return [NilClass]
+ # @return [nil]
def logout!
@token = nil unless @token.nil?
end
alias login= token=
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/errors.rb b/lib/genius/api/errors.rb
index 93799d0..8b22644 100644
--- a/lib/genius/api/errors.rb
+++ b/lib/genius/api/errors.rb
@@ -43,7 +43,7 @@ module Genius
# end
module Errors
# Endpoint for resource.
- ENDPOINT = "#{Api::RESOURCE}/account/?access_token"
+ ENDPOINT = "#{Api::RESOURCE}/account/?access_token".freeze
# Abstract class to store all exception classes in a single object.
class GeniusExceptionSuperClass < StandardError
@@ -59,11 +59,14 @@ class GeniusExceptionSuperClass < StandardError
class TokenError < GeniusExceptionSuperClass
attr_reader :msg, :exception_type, :method_name
- # @param [String (frozen)] msg Exception message.
- # @param [String (frozen)] exception_type Exception type.
- # @return [String (frozen)]
- def initialize(msg: "Invalid token. The access token provided is expired, revoked, malformed or invalid for " \
- "other reasons.", exception_type: "token_error", method_name: nil)
+ # Initializes a token validation error with optional method name hint.
+ #
+ # @param [String] msg Error message.
+ # @param [String] exception_type Error type identifier.
+ # @param [String?] method_name Optional method name for user hint.
+ # @return [void]
+ def initialize(msg: 'Invalid token. The access token provided is expired, revoked, malformed or invalid for ' \
+ 'other reasons.', exception_type: 'token_error', method_name: nil)
@msg = if method_name.nil?
msg
else
@@ -79,10 +82,12 @@ def initialize(msg: "Invalid token. The access token provided is expired, revoke
class LyricsNotFoundError < GeniusExceptionSuperClass
attr_reader :msg, :exception_type
- # @param [String (frozen)] msg Exception message.
- # @param [String (frozen)] exception_type Exception type.
- # @return [String (frozen)]
- def initialize(msg: "Lyrics not found in current session. Retrying...", exception_type: "invalid_lyrics")
+ # Initializes a lyrics-not-found error.
+ #
+ # @param [String] msg Error message.
+ # @param [String] exception_type Error type identifier.
+ # @return [void]
+ def initialize(msg: 'Lyrics not found in current session. Retrying...', exception_type: 'invalid_lyrics')
@msg = msg
@exception_type = exception_type
super(msg)
@@ -94,25 +99,23 @@ def initialize(msg: "Lyrics not found in current session. Retrying...", exceptio
class PageNotFound < GeniusExceptionSuperClass
attr_reader :msg, :exception_type
- # @param [String (frozen)] msg Exception message.
- # @param [String (frozen)] exception_type Exception type.
- # @return [String (frozen)]
- def initialize(msg: "Page not found. Try again with another response", exception_type: "page_not_found")
+ # Initializes a page-not-found error.
+ #
+ # @param [String] msg Error message.
+ # @param [String] exception_type Error type identifier.
+ # @return [void]
+ def initialize(msg: 'Page not found. Try again with another response', exception_type: 'page_not_found')
@msg = msg
@exception_type = exception_type
super(msg)
end
- # +Genius::Errors::PageNotFound.page_not_found?+ -> true or false
- #
- # +PageNotFound.page_not_found?+ method is used to be a predicate for
- # handling 404 error.
+ # Checks if the HTML response indicates a page-not-found error.
#
- # @param [Object] html
- # @return [TrueClass] if genius page is not found
- # @return [FalseClass] if genius page is found
+ # @param [Object] html Parsed HTML document.
+ # @return [Boolean]
def self.page_not_found?(html)
- html.text.match?(/Page not found/)
+ html.text.include?('Page not found')
end
end
@@ -121,16 +124,10 @@ def self.page_not_found?(html)
# +Genius::Errors+ scope.
module DynamicRescue
class << self
- # +Genius::Errors::DynamicRescue.rescue+ -> value
- #
- # +Genius::Errors::DynamicRescue.rescue_from+ is a helper method,
- # which, according to reflection, redefine singleton method for
- # specified module, adding to it exception handler for DRY pattern.
- #
- # @todo: add docs
+ # Wraps singleton methods of +klass+ with exception handling via {DynamicRescue.rescue_from}.
#
- # @param [Object] klass Class name of structure - module/class/etc.
- # @return [Object]
+ # @param [Module] klass Module whose singleton methods to wrap.
+ # @return [Array]
def rescue(klass)
DynamicRescue.rescue_from klass.singleton_methods, klass, GeniusExceptionSuperClass do |e|
puts "Error description: #{e.msg}\nException type: #{e.exception_type}"
@@ -138,20 +135,20 @@ def rescue(klass)
end
end
- # @param [Object] meths List of methods to redefine.
- # @param [Object] klass Class name of structure - module/class/etc.
- # @param [Object] exception Exception class.
- # @param [Proc] handler Body of rescue block.
- # @return [Object]
- def rescue_from(meths, klass, exception, &handler)
+ # Redefines each method in +meths+ on +klass+ to rescue +exception+ and yield to the block.
+ #
+ # @param [Array] meths Method names to wrap.
+ # @param [Module] klass Module to redefine methods on.
+ # @param [Module] exception Exception class to rescue.
+ # @raise [StandardError]
+ # @return [Array]
+ def rescue_from(meths, klass, exception, &)
meths.each do |meth|
- # store the previous implementation
old = klass.singleton_method(meth)
- # wrap it
- klass.define_singleton_method(meth) do |*args|
- old.unbind.bind(klass).call(*args)
+ klass.define_singleton_method(meth) do |*args, **kwargs|
+ old.unbind.bind(klass).call(*args, **kwargs) # steep:ignore
rescue exception => e
- handler.call(e)
+ yield(e)
end
end
end
@@ -159,78 +156,54 @@ def rescue_from(meths, klass, exception, &handler)
end
class << self
+ # Validates the access token by checking length and making a test request to the API.
+ #
+ # @param [String?] token Token to validate.
+ # @param [String?] method_name Optional method name for error hints.
+ # @raise [StandardError] if +token+ is nil, wrong length, or invalid.
+ # @return [void]
def validate_token(token, method_name: nil)
- raise TokenError, method_name: method_name if token.nil? || token.size != 64
+ raise TokenError.new(method_name: method_name) if token.nil? || token.size != 64
response = HTTParty.get("#{ENDPOINT}=#{token}").body
- status = JSON.parse(response).dig("meta", "status")
- raise TokenError, method_name: method_name unless status == 200
+ status = JSON.parse(response).dig('meta', 'status')
+ raise TokenError.new(method_name: method_name) unless status == 200
end
- # +Genius::Errors.error_handle(token)+ -> true or false
+ # Validates token and raises on failure. Returns +true+ if valid.
#
- # @deprecated Since 0.2.1
- # @param [String] token Token to access https://api.genius.com.
- # @param [NilClass or String] method_name Optional param to pass method
- # name where exception was raised.
+ # @deprecated Use {.validate_token} instead.
+ # @param [String?] token Token to validate.
+ # @param [String?] method_name Optional method name for error hints.
+ # @raise [StandardError] if token is invalid.
# @return [Boolean]
- #
- # @example
- # begin
- # Genius::Errors.validate_token(token)
- # rescue Genius::Errors::TokenError => e
- # puts e.message
- # puts e.exception_type
- # end
- # This method is necessary to handle all errors during validation.
- # +token+ param is not optional and it is needed to validate token
- # itself. +method_name+ param optional and it to passes
- # method name in error exception for dynamical error message, and
- # because of unimportance this method is
- # +nil+ by default. If you are ready to pass method, it will look like
- # this:
- #
- # @example
- # begin
- # Genius::Errors.error_handle(token, method_name: __method__)
- # rescue Genius::Errors::TokenError => e
- # puts e.message
- # puts e.exception_type
- # end
- def error_handle(token, method_name: nil)
+ def error_handle?(token, method_name: nil)
if token.nil?
- raise TokenError.new(msg: "Token is required for this method. Please, add token via " \
- "`Genius::Auth.login=``token''` method and continue",
+ raise TokenError.new(msg: 'Token is required for this method. Please, add token via ' \
+ "`Genius::Auth.login=``token''` method and continue",
method_name: method_name)
- elsif token.size != 64 || check_status(token) == false
- raise TokenError, method_name: method_name
+ elsif token.size != 64 || check_status?(token) == false
+ raise TokenError.new(method_name: method_name)
end
true
end
private
- # +Genius::Errors.check_status(token)+ -> true or false
- #
- # @deprecated Since 0.2.1
- # This method was made to check token state. Token must be 64-sized
- # string and could be validated only if response status equals 200.
- # More description in {docs}[https://docs.genius.com/] and
- # {api-clients page}[https://genius.com/api-clients] or in
- # {TokenError documentation}[Genius::Auth.TokenError].
+ # Checks if the token returns a 200 status from the API.
#
+ # @deprecated Use {.validate_token} instead.
# @private
- # @param [String] token Token to access https://api.genius.com.
+ # @param [String] token Token to check.
+ # @raise [TokenError] if the response status is not 200.
# @return [Boolean]
- #
- # @see .error_handle
- def check_status(token)
+ def check_status?(token)
return false if token.size != 64 || token.nil?
response = HTTParty.get("#{ENDPOINT}=#{token}").body
- raise TokenError unless JSON.parse(response).dig("meta", "status")
+ raise TokenError unless JSON.parse(response).dig('meta', 'status')
- status = JSON.parse(response).dig("meta", "status")
+ status = JSON.parse(response).dig('meta', 'status')
status == 200
end
end
diff --git a/lib/genius/api/referents.rb b/lib/genius/api/referents.rb
index 292a914..e67c65f 100644
--- a/lib/genius/api/referents.rb
+++ b/lib/genius/api/referents.rb
@@ -10,36 +10,20 @@ module Genius
module Referents
class << self
# Endpoint of the resource
- ENDPOINT = "#{Api::RESOURCE}/referents"
- # +Genius::Referents.referents+ -> Hash
+ ENDPOINT = "#{Api::RESOURCE}/referents".freeze
+ # Referents by content item or user. Pass only one of +:song_id+ and +:web_page+.
#
- # Referents by content item or user responsible for an included
- # annotation. You may pass only one of song_id and web_page_id, not both.
- #
- # @param [Hash] options
- # @option options [Integer] :created_by_id ID of a user to get
- # referents for.
- # @option options [String] :text_format Format for text bodies related
- # to the document. One or more of +dom+, +plain+, and +html+,
- # separated by commas (defaults to +dom+). See details of each option
- # {here}[https://docs.genius.com/#response-format-h1].
- # @option options [Integer] :web_page_id ID of a web page to get
- # referents for.
- # @option options [Integer] :song_id ID of a song to get referents for.
- # @option options [Integer] :per_page Number of results to return per
- # request.
- # @option options [Integer] :page Paginated offset,
- # (e.g., per_page=5&page=3 returns songs 11-15).
- # @raise [ArgumentError] if +song_id+ and +web_page+ are presented in
- # the same scope.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [Hash]
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Hash] options Options: +:created_by_id+, +:text_format+, +:web_page_id+,
+ # +:song_id+, +:per_page+, +:page+.
+ # @raise [ArgumentError] if both +:song_id+ and +:web_page+ are present.
+ # @return [Hash, nil]
def referents(token: nil, options: {})
return if token.nil? && !Auth.authorized?.nil?
Errors.validate_token(token) unless token.nil?
if options.key?(:web_page) && options.key?(:song_id)
- raise ArgumentError, "You may pass only one of song_id and web_page_id, not both!"
+ raise ArgumentError, 'You may pass only one of song_id and web_page_id, not both!'
end
params = options_helper(options, %i[created_by_id text_format per_page page])
@@ -48,7 +32,7 @@ def referents(token: nil, options: {})
JSON.parse(response)
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/search.rb b/lib/genius/api/search.rb
index 5360efa..d0b9276 100644
--- a/lib/genius/api/search.rb
+++ b/lib/genius/api/search.rb
@@ -4,36 +4,12 @@ module Genius
# +Genius::Search+ module provides methods to work with Genius search database
module Search
class << self
- # +Genius::Search.search+ -> NilClass
+ # Searches Genius for songs, artists, and other content. Optionally filters results by key using +deep_find+.
#
- # @param [String] token Token to access https://api.genius.com.
- # @param [String] query Search query.
- # @param [Object] search_by Optional parameter to search by key in
- # output +JSON+.
- # @raise [ArgumentError] if +query+ got incorrect value.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [String] if +search_by+ is +TrueClass+.
- # @return [Hash] if +search_by+ is +FalseClass+.
- # @return [NilClass] if TokenError exception raised.
- # @todo: refactor example group in method documentation
- # This method is a standard Genius API
- # {method}[https://docs.genius.com/#search-h2] and it is needed to send
- # a request to the server and get information about artists, tracks and
- # everything else that may be inside the response body. According to
- # https://docs.genius.com/#search-h2, token is required to be in
- # response, but you can use this method without use of it! =)
- #
- # @example
- # Genius::Search.search(query: "Ariana Grande") #=> {..., "full_title"=>" thank u, next by Ariana Grande", ...}
- #
- # Also, you can use this method with +search_by+ param, which is
- # needed to search interested data through returned +JSON+. It uses
- # +deep_find+ extension under the hood.
- #
- # @example
- # Genius::Search.search(query: "Bones", search_by: "title") #=> ["Dirt", "HDMI", "RestInPeace", "Sodium"]
- #
- # @see #deep_find
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [String?] query Search query.
+ # @param [String?] search_by Key to filter results with {Hash#deep_find}.
+ # @return [Hash, String, nil]
def search(token: nil, query: nil, search_by: nil)
return if token.nil? && !Auth.authorized?.nil?
@@ -44,7 +20,7 @@ def search(token: nil, query: nil, search_by: nil)
search_by ? search.deep_find(search_by) : search
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/songs.rb b/lib/genius/api/songs.rb
index 6871ab9..017049e 100644
--- a/lib/genius/api/songs.rb
+++ b/lib/genius/api/songs.rb
@@ -6,24 +6,12 @@ module Songs
class << self
include Genius::Errors
- # +Genius::Songs.songs+ -> NilClass
+ # Returns song data by ID. Optionally merges lyrics from the Genius page when +combine+ is +true+.
#
- # This method provides info about song by its id. It is not the same
- # with +Genius::Search.search+ method, because it modify a +JSON+ only
- # for concrete song id, not for whole search database, which is returned
- # in +Genius::Search.search+.
- #
- # @param [String] token Token to access https://api.genius.com.
- # @param [Integer] song_id Song id.
- # @raise [PageNotFound] if page is not found.
- # @raise [LyricsNotFound] if output JSON is nil.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [String] the error message if +lyrics+ param is +true+.
- # @return [Hash] if +lyrics+ param is +false+.
- # @return [NilClass] if TokenError exception raised.
- #
- # @example
- # Genius::Songs.songs(song_id: 294649) #=> {"some_kind_of_hash"}
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Integer?] song_id ID of the song.
+ # @param [Boolean] combine If +true+, fetches and merges lyrics into the response.
+ # @return [Hash, String, nil]
def songs(token: nil, song_id: nil, combine: false)
return if token.nil? && !Auth.authorized?.nil?
@@ -31,50 +19,66 @@ def songs(token: nil, song_id: nil, combine: false)
response = HTTParty.get("#{Api::RESOURCE}/songs/#{song_id}?access_token=#{token_ext(token)}").body
response = JSON.parse response
- if combine
- begin
- output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}"))
- raise PageNotFound if PageNotFound.page_not_found?(output_html)
+ combine && song_id ? merge_lyrics(song_id, response) : response
+ end
- # @todo: maybe need some optimisations
- unformed_json = output_html.css("script")[17]
- .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(.+?))'\);/)
- raise LyricsNotFoundError if unformed_json.nil?
+ private
- formatted_json = unformed_json[:json]
- lyrics_json = JSON.parse(formatted_json.unescape)
- response["lyrics"] = lyrics_json
- return response
- rescue LyricsNotFoundError
- retry
- rescue PageNotFound => e
- "Error description: #{e.msg}\nException type: #{e.exception_type}"
- end
- end
+ # Fetches the Genius HTML page for a song and merges lyrics into the API response.
+ #
+ # @private
+ # @param [Integer] song_id ID of the song.
+ # @param [Hash] response Original API response hash.
+ # @raise [Errors::PageNotFound] if the song page is not found.
+ # @raise [Errors::LyricsNotFoundError] if lyrics cannot be parsed.
+ # @return [Hash, String]
+ def merge_lyrics(song_id, response)
+ output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}"))
+ raise Errors::PageNotFound if Errors::PageNotFound.page_not_found?(output_html)
+
+ response['lyrics'] = parse_preloaded_state(output_html)
response
+ rescue Errors::LyricsNotFoundError
+ retry
+ rescue Errors::PageNotFound => e
+ "Error description: #{e.msg}\nException type: #{e.exception_type}"
end
- # +Genius::Songs.get_lyrics+ -> Hash
+ # Extracts the preloaded state JSON from the Genius page HTML.
#
- # +Genius::Songs.get_lyrics+ method is used for extracting lyrics in
- # plain text format.
- #
- # @param [Integer] song_id Song id.
- # @raise [ArgumentError] if +song_id+ is blank.
+ # @private
+ # @param [Object] output_html Parsed Nokogiri HTML document.
+ # @raise [Errors::LyricsNotFoundError] if the preloaded state script is not found.
# @return [Hash]
+ def parse_preloaded_state(output_html)
+ unformed_json = output_html.css('script')[17]
+ .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(?:.+?))'\);/)
+ raise Errors::LyricsNotFoundError if unformed_json.nil?
+
+ JSON.parse(unformed_json[:json].unescape)
+ end
+
+ public
+
+ # Extracts lyrics as plain text from the Genius song page.
+ #
+ # @param [Integer] song_id ID of the song.
+ # @raise [ArgumentError] if +song_id+ is nil.
+ # @raise [NoMethodError]
+ # @return [String]
def get_lyrics(song_id)
- raise ArgumentError, "`song_id` should be not blank!" if song_id.nil?
+ raise ArgumentError, '`song_id` should be not blank!' if song_id.nil?
response = HTTParty.get("https://genius.com/songs/#{song_id}")
document = Nokogiri::HTML(response)
- # @todo: something wrong with lyrics attribute value
+ # @todo: something wrong with lyrics attribute value
lyrics_path = document.xpath("//*[@class='Lyrics__Container-sc-1ynbvzw-6 YYrds']")
- lyrics_path.at_css("p").content
+ lyrics_path.at_css('p').content
rescue NoMethodError
retry
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/lib/genius/api/version.rb b/lib/genius/api/version.rb
index ca7dfb8..f856244 100644
--- a/lib/genius/api/version.rb
+++ b/lib/genius/api/version.rb
@@ -3,6 +3,6 @@
module Genius
module Api
# Version of the gem.
- VERSION = "0.2.1"
+ VERSION = '0.3.0'
end
end
diff --git a/lib/genius/api/web_pages.rb b/lib/genius/api/web_pages.rb
index 65bd56e..085e3e5 100644
--- a/lib/genius/api/web_pages.rb
+++ b/lib/genius/api/web_pages.rb
@@ -5,38 +5,22 @@ module Genius
# be attached. Web pages map 1-to-1 with unique, canonical URLs.
module WebPages
class << self
- # +Genius::WebPages.lookup+ -> value
+ # Looks up a web page by URL variants and returns Genius metadata.
#
- # Information about a web page retrieved by the page's full URL
- # (including protocol). The returned data includes Genius's ID for the
- # page, which may be used to look up associated referents with the
- # {/referents}[https://docs.genius.com/#/referents-index] endpoint.
- #
- # Data is only available for pages that already have at least one
- # annotation.
- #
- # Provide as many of the following variants of the URL as possible:
- # @param [Hash] options
- # @option options [String] :raw_annotatable_url The URL as it would
- # appear in a browser.
- # @option options [String] :canonical_url The URL as specified by an
- # appropriate tag in a page's .
- # @option options [String] :og_url The URL as specified by an
- # og:url tag in a page's .
- # @raise [ArgumentError] if +song_id+ is blank.
- # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
- # @return [Hash]
+ # @param [String?] token Token to access https://api.genius.com.
+ # @param [Hash] options URL variants: +:raw_annotatable_url+, +:canonical_url+, +:og_url+.
+ # @return [Hash, nil]
def lookup(token: nil, options: {})
return if token.nil? && !Auth.authorized?.nil?
Errors.validate_token(token) unless token.nil?
params = options_helper(options, %i[raw_annotatable_url canonical_url og_url])
- response = HTTParty.get("#{Api::RESOURCE}/?access_token=#{token_ext(token)}#{params}")
+ response = HTTParty.get("#{Api::RESOURCE}/?access_token=#{token_ext(token)}#{params}").body
JSON.parse(response)
end
- Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name))
+ Genius::Errors::DynamicRescue.rescue(Module.nesting[1])
end
end
end
diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml
new file mode 100644
index 0000000..d282d3f
--- /dev/null
+++ b/rbs_collection.lock.yaml
@@ -0,0 +1,232 @@
+---
+path: ".gem_rbs_collection"
+gems:
+- name: ast
+ version: '2.4'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: bigdecimal
+ version: 4.1.2
+ source:
+ type: rubygems
+- name: concurrent-ruby
+ version: '1.1'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: csv
+ version: '3.3'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: diff-lcs
+ version: '1.5'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: ffi
+ version: 1.17.4
+ source:
+ type: rubygems
+- name: fileutils
+ version: '0'
+ source:
+ type: stdlib
+- name: forwardable
+ version: '0'
+ source:
+ type: stdlib
+- name: httparty
+ version: '0.18'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: json
+ version: '0'
+ source:
+ type: stdlib
+- name: lint_roller
+ version: '1.1'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: listen
+ version: '3.9'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: logger
+ version: '1.7'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: mini_mime
+ version: '0.1'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: monitor
+ version: '0'
+ source:
+ type: stdlib
+- name: multi_xml
+ version: 0.9.1
+ source:
+ type: rubygems
+- name: net-http
+ version: '0'
+ source:
+ type: stdlib
+- name: net-protocol
+ version: '0'
+ source:
+ type: stdlib
+- name: nokogiri
+ version: '1.11'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: optparse
+ version: '0'
+ source:
+ type: stdlib
+- name: parallel
+ version: '1.20'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: parser
+ version: '3.2'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: prism
+ version: 1.9.0
+ source:
+ type: rubygems
+- name: rainbow
+ version: '3.0'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: rake
+ version: '13.0'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: random-formatter
+ version: '0'
+ source:
+ type: stdlib
+- name: rbs
+ version: 4.0.2
+ source:
+ type: rubygems
+- name: rdoc
+ version: '0'
+ source:
+ type: stdlib
+- name: regexp_parser
+ version: '2.8'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: ripper
+ version: '0'
+ source:
+ type: stdlib
+- name: rubocop
+ version: '1.57'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: rubocop-ast
+ version: '1.46'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+- name: securerandom
+ version: '0'
+ source:
+ type: stdlib
+- name: stringio
+ version: '0'
+ source:
+ type: stdlib
+- name: strscan
+ version: '0'
+ source:
+ type: stdlib
+- name: timeout
+ version: '0'
+ source:
+ type: stdlib
+- name: tsort
+ version: '0'
+ source:
+ type: stdlib
+- name: uri
+ version: '0'
+ source:
+ type: stdlib
+- name: yard
+ version: '0.9'
+ source:
+ type: git
+ name: ruby/gem_rbs_collection
+ revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ repo_dir: gems
+gemfile_lock_path: Gemfile.lock
diff --git a/rbs_collection.yaml b/rbs_collection.yaml
new file mode 100644
index 0000000..f8c7ed5
--- /dev/null
+++ b/rbs_collection.yaml
@@ -0,0 +1,14 @@
+# Download sources
+sources:
+ - type: git
+ name: ruby/gem_rbs_collection
+ remote: https://github.com/ruby/gem_rbs_collection.git
+ revision: main
+ repo_dir: gems
+
+# A directory to install the downloaded RBSs
+path: .gem_rbs_collection
+
+gems:
+ - name: activesupport
+ ignore: true
diff --git a/sig/lib/extensions/deep_find.rbs b/sig/lib/extensions/deep_find.rbs
new file mode 100644
index 0000000..1fcdc6c
--- /dev/null
+++ b/sig/lib/extensions/deep_find.rbs
@@ -0,0 +1,30 @@
+class Hash[unchecked out K, unchecked out V]
+ # +Hash#deep_find+ -> value
+ #
+ # @param [Object] key A key, which value should be found
+ # @param [FalseClass] uniq A flag to make values unique in an array
+ # @return [Object] output depends on key value
+ # This method is an extension for Hash core class to search for a value of a key in N-nested
+ # hash. It provides search for multiple values if key appears more than once. For e.g.:
+ #
+ # @example
+ # musicians = { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] },
+ # "Adele" => { "19" => ["Day Dreamer", "Best for Last"] },
+ # "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } }
+ # musicians.deep_find("19") #=> ["Day Dreamer", "Best for Last"]
+ # musicians.deep_find("Adele") #=> {"19"=>["Day Dreamer", "Best for Last"]}
+ # musicians.deep_find("28") #=> [["Highest in the Room", "Franchise"], ["Shape of You", "Castle on the Hill"]]
+ #
+ # If values are identical, they will be returned in a single copy. You can disable this
+ # feature with special param +uniq+, which is +true+ by default. For e.g.:
+ #
+ # @example
+ # h = {"a" => "b", "c" => {"a" => "b"}}
+ # h.deep_find("a") #=> "b", instead ["b", "b"]
+ # @todo change uniq true to uniq false
+ def deep_find: (untyped key, ?uniq: bool) -> (nil | untyped)
+
+ private
+
+ def collect_values: (untyped key) -> Array[untyped]
+end
diff --git a/sig/lib/extensions/options_helper.rbs b/sig/lib/extensions/options_helper.rbs
new file mode 100644
index 0000000..9591356
--- /dev/null
+++ b/sig/lib/extensions/options_helper.rbs
@@ -0,0 +1,3 @@
+class Object
+ def options_helper: (Hash[Symbol, untyped] options, Array[Symbol] arry) -> String
+end
diff --git a/sig/lib/extensions/token_ext.rbs b/sig/lib/extensions/token_ext.rbs
new file mode 100644
index 0000000..0f26285
--- /dev/null
+++ b/sig/lib/extensions/token_ext.rbs
@@ -0,0 +1,3 @@
+class Object
+ def token_ext: (String? token) -> String
+end
diff --git a/sig/lib/extensions/unescape.rbs b/sig/lib/extensions/unescape.rbs
new file mode 100644
index 0000000..1f91e2b
--- /dev/null
+++ b/sig/lib/extensions/unescape.rbs
@@ -0,0 +1,3 @@
+class String
+ def unescape: -> String
+end
diff --git a/sig/lib/genius/api.rbs b/sig/lib/genius/api.rbs
new file mode 100644
index 0000000..3935ac4
--- /dev/null
+++ b/sig/lib/genius/api.rbs
@@ -0,0 +1,9 @@
+# Base module which contains all of other methods/classes/etc.
+module Genius
+ # +Genius::Api+ is a base module with different constants.
+ module Api
+ # +Genius::Api::RESOURCE+ constant contains reference to
+ # {Genius API}[https://api.genius.com] resource.
+ RESOURCE: "https://api.genius.com"
+ end
+end
diff --git a/sig/lib/genius/api/account.rbs b/sig/lib/genius/api/account.rbs
new file mode 100644
index 0000000..23bd23d
--- /dev/null
+++ b/sig/lib/genius/api/account.rbs
@@ -0,0 +1,23 @@
+module Genius
+ # +Genius::Account+ module provides methods to work with Genius account
+ module Account
+ # +Genius::Account.account+ -> value
+ #
+ # An alias to {Genius::Account.account me} method
+ #
+ # @param [String] token Token to access https://api.genius.com.
+ # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid.
+ # @return [Hash]
+ # @return [NilClass] if TokenError exception raised.
+ # This method is a standard Genius API {request}[https://docs.genius.com/#search-h2] to get
+ # account info. Output +JSON+ is translated to Hash structure to make it easy to work with account fields.
+ #
+ # @example
+ # Genius::Auth.login="yuiaYqbncErCVwItjQxFspNWUZLhGpXrPbkvgbgHSEKJRAlToamzMfdOeDB"
+ # Genius::Account.account #=> {"meta"=>{"status"=>200}, "response"=>{"user"=>{...}}}
+ # @todo somehow refactor 50/52 exceptions
+ def self.account: (?token: String?) -> (Hash[String, untyped] | nil)
+
+ alias self.me self.account
+ end
+end
diff --git a/sig/lib/genius/api/annotations.rbs b/sig/lib/genius/api/annotations.rbs
new file mode 100644
index 0000000..0dced45
--- /dev/null
+++ b/sig/lib/genius/api/annotations.rbs
@@ -0,0 +1,13 @@
+module Genius
+ module Annotations
+ def self.annotations: (id: Integer, action: String?, token: String?, http_verb: String, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil)
+
+ private
+
+ def self.request: (id: Integer, action: String?, token: String?, http_verb: String, options: Hash[Symbol, untyped]) -> HTTParty::Response
+
+ def self.put_request: (id: Integer, action: String?, token: String?, options: Hash[Symbol, untyped]) -> HTTParty::Response
+
+ def self.post_payload: (?options: Hash[Symbol, untyped]) -> String
+ end
+end
diff --git a/sig/lib/genius/api/artists.rbs b/sig/lib/genius/api/artists.rbs
new file mode 100644
index 0000000..c40ed47
--- /dev/null
+++ b/sig/lib/genius/api/artists.rbs
@@ -0,0 +1,15 @@
+module Genius
+ module Artists
+ def self.artists: (?token: String?, ?id: Integer?) -> (Hash[String, untyped] | nil)
+
+ def self.artists_songs: (?token: String?, ?id: Integer?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil)
+
+ private
+
+ def self.validate: (Array[String] sort_values, **untyped options) -> void
+
+ def self.validate_sort: (String? sort, Array[String] sort_values) -> void
+
+ def self.validate_page_per_page: (Integer? page_per_page) -> void
+ end
+end
diff --git a/sig/lib/genius/api/authorization.rbs b/sig/lib/genius/api/authorization.rbs
new file mode 100644
index 0000000..346e037
--- /dev/null
+++ b/sig/lib/genius/api/authorization.rbs
@@ -0,0 +1,11 @@
+module Genius
+ module Auth
+ def self.token=: (String token) -> String
+
+ def self.authorized?: (?String token, ?method_name: String) -> bool
+
+ def self.logout!: -> nil
+
+ # alias login= token=
+ end
+end
diff --git a/sig/lib/genius/api/errors.rbs b/sig/lib/genius/api/errors.rbs
new file mode 100644
index 0000000..21fbc67
--- /dev/null
+++ b/sig/lib/genius/api/errors.rbs
@@ -0,0 +1,46 @@
+module Genius
+ module Errors
+ ENDPOINT: String
+
+ class GeniusExceptionSuperClass < StandardError
+ end
+
+ class TokenError < GeniusExceptionSuperClass
+ attr_reader msg: String
+ attr_reader exception_type: String
+ attr_reader method_name: String?
+
+ def initialize: (?msg: String, ?exception_type: String, ?method_name: String?) -> void
+ end
+
+ class LyricsNotFoundError < GeniusExceptionSuperClass
+ attr_reader msg: String
+ attr_reader exception_type: String
+
+ def initialize: (?msg: String, ?exception_type: String) -> void
+ end
+
+ class PageNotFound < GeniusExceptionSuperClass
+ attr_reader msg: String
+ attr_reader exception_type: String
+
+ def initialize: (?msg: String, ?exception_type: String) -> void
+
+ def self.page_not_found?: (untyped html) -> bool
+ end
+
+ module DynamicRescue
+ def self.rescue: (Module klass) -> Array[Symbol]
+
+ def self.rescue_from: (Array[Symbol] meths, Module klass, Module exception) { (untyped) -> void } -> Array[Symbol]
+ end
+
+ def self.validate_token: (String? token, ?method_name: String?) -> void
+
+ def self.error_handle?: (String? token, ?method_name: String?) -> bool
+
+ private
+
+ def self.check_status?: (String token) -> bool
+ end
+end
diff --git a/sig/lib/genius/api/referents.rbs b/sig/lib/genius/api/referents.rbs
new file mode 100644
index 0000000..bc2f079
--- /dev/null
+++ b/sig/lib/genius/api/referents.rbs
@@ -0,0 +1,7 @@
+module Genius
+ module Referents
+ ENDPOINT: String
+
+ def self.referents: (?token: String?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil)
+ end
+end
diff --git a/sig/lib/genius/api/search.rbs b/sig/lib/genius/api/search.rbs
new file mode 100644
index 0000000..f43359e
--- /dev/null
+++ b/sig/lib/genius/api/search.rbs
@@ -0,0 +1,5 @@
+module Genius
+ module Search
+ def self.search: (?token: String?, ?query: String?, ?search_by: String?) -> (Hash[String, untyped] | String | nil)
+ end
+end
diff --git a/sig/lib/genius/api/songs.rbs b/sig/lib/genius/api/songs.rbs
new file mode 100644
index 0000000..0e6b175
--- /dev/null
+++ b/sig/lib/genius/api/songs.rbs
@@ -0,0 +1,15 @@
+module Genius
+ module Songs
+ def self.songs: (?token: String?, ?song_id: Integer?, ?combine: bool) -> (Hash[String, untyped] | String | nil)
+
+ private
+
+ def self.merge_lyrics: (Integer song_id, Hash[String, untyped] response) -> (Hash[String, untyped] | String)
+
+ def self.parse_preloaded_state: (untyped output_html) -> Hash[String, untyped]
+
+ public
+
+ def self.get_lyrics: (Integer song_id) -> String
+ end
+end
diff --git a/sig/lib/genius/api/version.rbs b/sig/lib/genius/api/version.rbs
new file mode 100644
index 0000000..42bb2c0
--- /dev/null
+++ b/sig/lib/genius/api/version.rbs
@@ -0,0 +1,5 @@
+module Genius
+ module Api
+ VERSION: String
+ end
+end
diff --git a/sig/lib/genius/api/web_pages.rbs b/sig/lib/genius/api/web_pages.rbs
new file mode 100644
index 0000000..96896c9
--- /dev/null
+++ b/sig/lib/genius/api/web_pages.rbs
@@ -0,0 +1,5 @@
+module Genius
+ module WebPages
+ def self.lookup: (?token: String?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil)
+ end
+end
diff --git a/spec/api/account_spec.rb b/spec/api/account_spec.rb
new file mode 100644
index 0000000..65e86b2
--- /dev/null
+++ b/spec/api/account_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Account do
+ describe '.account' do
+ context 'with valid token' do
+ let(:token) { 'a' * 64 }
+ let(:response_body) do
+ { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json
+ end
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before do
+ allow(HTTParty).to receive(:get).and_return(mock_response)
+ end
+
+ it 'returns account info as a hash' do
+ result = described_class.account(token: token)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the account endpoint' do
+ described_class.account(token: token)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/account?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'without token' do
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.account(token: nil)).to be_nil
+ end
+ end
+ end
+
+ describe '.me' do
+ let(:token) { 'a' * 64 }
+ let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before do
+ allow(HTTParty).to receive(:get).and_return(mock_response)
+ end
+
+ it 'returns the same result as .account' do
+ expect(described_class.me(token: token)).to eq(described_class.account(token: token))
+ end
+ end
+end
diff --git a/spec/api/annotations_spec.rb b/spec/api/annotations_spec.rb
new file mode 100644
index 0000000..84dd824
--- /dev/null
+++ b/spec/api/annotations_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Annotations do
+ describe '.annotations' do
+ context 'with GET verb' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'sends a GET request' do
+ described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get')
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}"
+ )
+ end
+
+ it 'returns parsed JSON' do
+ result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get')
+ expect(result).to be_a(Hash)
+ end
+ end
+
+ context 'with POST verb' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:post).and_return(mock_response)
+ end
+
+ it 'sends a POST request' do
+ described_class.annotations(
+ id: annotation_id, action: nil, token: token, http_verb: 'post',
+ options: { markdown: 'test', raw_annotatable_url: 'https://example.com' }
+ )
+ expect(HTTParty).to have_received(:post)
+ end
+ end
+
+ context 'with PUT verb and no action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:put).and_return(mock_response)
+ end
+
+ it 'sends a PUT request' do
+ described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'put')
+ expect(HTTParty).to have_received(:put)
+ end
+ end
+
+ context 'with PUT verb and upvote action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:put).and_return(mock_response)
+ end
+
+ it 'sends a PUT request to the upvote endpoint' do
+ described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'put')
+ expect(HTTParty).to have_received(:put).with(
+ "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with PUT verb and downvote action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:put).and_return(mock_response)
+ end
+
+ it 'sends a PUT request to the downvote endpoint' do
+ described_class.annotations(id: annotation_id, action: 'downvote', token: token, http_verb: 'put')
+ expect(HTTParty).to have_received(:put).with(
+ "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with PUT verb and unvote action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:put).and_return(mock_response)
+ end
+
+ it 'sends a PUT request to the unvote endpoint' do
+ described_class.annotations(id: annotation_id, action: 'unvote', token: token, http_verb: 'put')
+ expect(HTTParty).to have_received(:put).with(
+ "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with PUT verb and invalid action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.annotations(id: annotation_id, action: 'invalid', token: token, http_verb: 'put')
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with DELETE verb' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:delete).and_return(mock_response)
+ end
+
+ it 'sends a DELETE request' do
+ described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'delete')
+ expect(HTTParty).to have_received(:delete).with(
+ "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with non-PUT verb and action' do
+ let(:token) { 'a' * 64 }
+ let(:annotation_id) { 10_225_840 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'get')
+ end.to raise_error(ArgumentError, /only PUT accepts/)
+ end
+ end
+ end
+
+ describe '.post_payload' do
+ let(:post_options) do
+ {
+ markdown: 'hello **world!**',
+ raw_annotatable_url: 'https://example.com',
+ fragment: 'test',
+ before_html: '
',
+ after_html: '
',
+ canonical_url: 'https://example.com/canonical',
+ og_url: 'https://example.com/og',
+ title: 'Test Page'
+ }
+ end
+
+ it 'builds a JSON payload from options' do
+ payload = described_class.send(:post_payload, options: post_options)
+ parsed = JSON.parse(payload)
+ expect(parsed.dig('annotation', 'body', 'markdown')).to eq('hello **world!**')
+ end
+
+ it 'includes referent url in the payload' do
+ payload = described_class.send(:post_payload, options: post_options)
+ parsed = JSON.parse(payload)
+ expect(parsed.dig('referent', 'raw_annotatable_url')).to eq('https://example.com')
+ end
+
+ it 'includes web page title in the payload' do
+ payload = described_class.send(:post_payload, options: post_options)
+ parsed = JSON.parse(payload)
+ expect(parsed.dig('web_page', 'title')).to eq('Test Page')
+ end
+ end
+end
diff --git a/spec/api/artists_spec.rb b/spec/api/artists_spec.rb
new file mode 100644
index 0000000..600de93
--- /dev/null
+++ b/spec/api/artists_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Artists do
+ describe '.artists' do
+ context 'with valid data' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => { 'artist' => { 'id' => artist_id, 'name' => 'Test Artist' } }
+ }.to_json
+ end
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns artist data as a hash' do
+ result = described_class.artists(token: token, id: artist_id)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the artists endpoint' do
+ described_class.artists(token: token, id: artist_id)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/artists/#{artist_id}?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with nil id' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError)
+ end
+ end
+ end
+
+ describe '.artists_songs' do
+ context 'with valid data' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => { 'songs' => [{ 'id' => 1, 'title' => 'Song 1' }] }
+ }.to_json
+ end
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns songs data as a hash' do
+ result = described_class.artists_songs(token: token, id: artist_id)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the artists songs endpoint' do
+ described_class.artists_songs(token: token, id: artist_id)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/artists/#{artist_id}?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with sort option' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'includes sort parameter' do
+ described_class.artists_songs(token: token, id: artist_id, options: { sort: 'popularity' })
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity"
+ )
+ end
+ end
+
+ context 'with invalid sort' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.artists_songs(token: token, id: artist_id, options: { sort: 'invalid' })
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with negative per_page' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 })
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with negative page' do
+ let(:token) { 'a' * 64 }
+ let(:artist_id) { 16_764 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.artists_songs(token: token, id: artist_id, options: { page: -1 })
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'without token' do
+ let(:artist_id) { 16_764 }
+
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/api/authorization_spec.rb b/spec/api/authorization_spec.rb
index d0829e6..7512a90 100644
--- a/spec/api/authorization_spec.rb
+++ b/spec/api/authorization_spec.rb
@@ -1,38 +1,40 @@
# frozen_string_literal: true
-require "rspec"
-require "dotenv/load"
-require "genius/api"
+require 'genius/api'
describe Genius::Auth do
- let!(:auth) { described_class }
- # @todo Change .env to .env.local
- let(:token) { ENV["TOKEN"] }
+ describe '.authorized?' do
+ context 'with valid token' do
+ let(:valid_token) { 'a' * 64 }
- describe ".authorized?" do
- context "when a valid token is provided" do
before do
- auth.login = token
+ described_class.logout!
+ allow(Genius::Errors).to receive(:validate_token)
+ described_class.login = valid_token
end
- it "returns true" do
- expect(auth.authorized?).to be true
+ it 'returns true' do
+ expect(described_class.authorized?).to be true
end
end
- context "when an invalid token is provided" do
- it "returns false" do
- auth.logout!
- auth.login = "invalid_token"
- expect(auth.authorized?).to be false
+ context 'with invalid token' do
+ before do
+ described_class.logout!
+ allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new)
+ end
+
+ it 'returns false' do
+ described_class.login = 'invalid_token'
+ expect(described_class.authorized?).to be false
end
end
end
- describe ".logout!" do
- it "sets the token to nil" do
- auth.logout!
- expect(auth.instance_variable_get(:@token)).to be_nil
+ describe '.logout!' do
+ it 'sets the token to nil' do
+ described_class.logout!
+ expect(described_class.instance_variable_get(:@token)).to be_nil
end
end
end
diff --git a/spec/api/errors_spec.rb b/spec/api/errors_spec.rb
new file mode 100644
index 0000000..089b8e5
--- /dev/null
+++ b/spec/api/errors_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Errors do
+ describe Genius::Errors::GeniusExceptionSuperClass do
+ it 'is a subclass of StandardError' do
+ expect(described_class.superclass).to eq(StandardError)
+ end
+ end
+
+ describe Genius::Errors::TokenError do
+ subject(:error) { described_class.new }
+
+ it 'has default message' do
+ expect(error.msg).to include('Invalid token')
+ end
+
+ it 'has default exception type' do
+ expect(error.exception_type).to eq('token_error')
+ end
+
+ context 'when method_name is provided' do
+ subject(:error) { described_class.new(method_name: 'Genius::Auth.authorized?') }
+
+ it 'includes method_name in the message' do
+ expect(error.msg).to include('Genius::Auth.authorized?')
+ end
+ end
+ end
+
+ describe Genius::Errors::LyricsNotFoundError do
+ subject(:error) { described_class.new }
+
+ it 'has default message' do
+ expect(error.msg).to eq('Lyrics not found in current session. Retrying...')
+ end
+
+ it 'has default exception type' do
+ expect(error.exception_type).to eq('invalid_lyrics')
+ end
+ end
+
+ describe Genius::Errors::PageNotFound do
+ subject(:error) { described_class.new }
+
+ it 'has default message' do
+ expect(error.msg).to eq('Page not found. Try again with another response')
+ end
+
+ it 'has default exception type' do
+ expect(error.exception_type).to eq('page_not_found')
+ end
+
+ describe '.page_not_found?' do
+ it "returns true if html contains 'Page not found'" do
+ html = Nokogiri::HTML('Page not found')
+ expect(described_class.page_not_found?(html)).to be true
+ end
+
+ it "returns false if html does not contain 'Page not found'" do
+ html = Nokogiri::HTML('OK')
+ expect(described_class.page_not_found?(html)).to be false
+ end
+ end
+ end
+
+ describe '.validate_token' do
+ context 'with nil token' do
+ it 'raises TokenError' do
+ expect { described_class.validate_token(nil) }.to raise_error(Genius::Errors::TokenError)
+ end
+ end
+
+ context 'with short token' do
+ let(:short_token) { 'short_token' }
+
+ it 'raises TokenError' do
+ expect { described_class.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError)
+ end
+ end
+
+ context 'with valid token and 200 response' do
+ let(:valid_token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'does not raise an error' do
+ expect { described_class.validate_token(valid_token) }.not_to raise_error
+ end
+ end
+
+ context 'with valid token and non-200 response' do
+ let(:valid_token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 401 } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'raises TokenError' do
+ expect { described_class.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError)
+ end
+ end
+ end
+
+ describe '.error_handle?' do
+ context 'with nil token' do
+ it 'raises TokenError with a specific message' do
+ expect { described_class.error_handle?(nil) }.to raise_error(Genius::Errors::TokenError)
+ end
+ end
+
+ context 'with short token' do
+ let(:short_token) { 'short_token' }
+
+ it 'raises TokenError' do
+ expect { described_class.error_handle?(short_token) }.to raise_error(Genius::Errors::TokenError)
+ end
+ end
+
+ context 'with valid token' do
+ let(:valid_token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns true' do
+ expect(described_class.error_handle?(valid_token)).to be true
+ end
+ end
+ end
+end
diff --git a/spec/api/referents_spec.rb b/spec/api/referents_spec.rb
new file mode 100644
index 0000000..f23ca20
--- /dev/null
+++ b/spec/api/referents_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Referents do
+ describe '.referents' do
+ context 'with valid token' do
+ let(:token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns referents as a hash' do
+ result = described_class.referents(token: token)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the referents endpoint' do
+ described_class.referents(token: token)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/referents?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with options' do
+ let(:token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json }
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'includes query parameters' do
+ described_class.referents(token: token, options: { per_page: 5, page: 2 })
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2"
+ )
+ end
+ end
+
+ context 'with both web_page and song_id' do
+ let(:token) { 'a' * 64 }
+
+ before { allow(Genius::Errors).to receive(:validate_token) }
+
+ it 'raises ArgumentError' do
+ expect do
+ described_class.referents(token: token, options: { web_page: 1, song_id: 2 })
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'without token' do
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.referents(token: nil)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/api/search_spec.rb b/spec/api/search_spec.rb
new file mode 100644
index 0000000..11343c9
--- /dev/null
+++ b/spec/api/search_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Search do
+ describe '.search' do
+ context 'with valid token and query' do
+ let(:token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => {
+ 'hits' => [
+ { 'result' => { 'title' => 'Song A' } },
+ { 'result' => { 'title' => 'Song B' } }
+ ]
+ }
+ }.to_json
+ end
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns search results as a hash' do
+ result = described_class.search(token: token, query: 'Ariana Grande')
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the search endpoint' do
+ described_class.search(token: token, query: 'Ariana Grande')
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with search_by' do
+ let(:token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => {
+ 'hits' => [
+ { 'result' => { 'title' => 'Song A' } },
+ { 'result' => { 'title' => 'Song B' } }
+ ]
+ }
+ }.to_json
+ end
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns filtered results' do
+ result = described_class.search(token: token, query: 'test', search_by: 'title')
+ expect(result).to contain_exactly('Song A', 'Song B')
+ end
+ end
+
+ context 'without token' do
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => {
+ 'hits' => [
+ { 'result' => { 'title' => 'Song A' } },
+ { 'result' => { 'title' => 'Song B' } }
+ ]
+ }
+ }.to_json
+ end
+
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.search(token: nil, query: 'test')).to be_nil
+ end
+ end
+
+ context 'with nil query' do
+ let(:token) { 'a' * 64 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => {
+ 'hits' => [
+ { 'result' => { 'title' => 'Song A' } },
+ { 'result' => { 'title' => 'Song B' } }
+ ]
+ }
+ }.to_json
+ end
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'sends a request with nil query' do
+ described_class.search(token: token, query: nil)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/search?q=&access_token=#{token}"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/api/songs_spec.rb b/spec/api/songs_spec.rb
new file mode 100644
index 0000000..2ab6aff
--- /dev/null
+++ b/spec/api/songs_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::Songs do
+ describe '.songs' do
+ context 'with valid data' do
+ let(:token) { 'a' * 64 }
+ let(:song_id) { 294_649 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => { 'song' => { 'id' => song_id, 'title' => 'Test Song' } }
+ }.to_json
+ end
+
+ before { allow(HTTParty).to receive(:get).and_return(mock_response) }
+
+ it 'returns song data as a hash' do
+ result = described_class.songs(token: token, song_id: song_id)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('response', 'song', 'id')).to eq(song_id)
+ end
+ end
+
+ it 'sends a GET request to the songs endpoint' do
+ described_class.songs(token: token, song_id: song_id)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/songs/#{song_id}?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with combine true' do
+ let(:token) { 'a' * 64 }
+ let(:song_id) { 294_649 }
+ let(:mock_response) { instance_double(HTTParty::Response, body: { 'meta' => { 'status' => 200 }, 'response' => { 'song' => { 'id' => song_id } } }.to_json) }
+ let(:html_body) do
+ scripts = (0..16).map { |i| "" } +
+ [""]
+ "#{scripts.join}"
+ end
+ let(:result) { described_class.songs(token: token, song_id: song_id, combine: true) }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:get).with("https://api.genius.com/songs/#{song_id}?access_token=#{token}")
+ .and_return(mock_response)
+ allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}")
+ .and_return(html_body)
+ end
+
+ it 'fetches lyrics page and includes lyrics in the response' do
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result).to have_key('lyrics')
+ expect(result['lyrics']).to have_key('lyrics')
+ end
+ end
+ end
+
+ context 'without token' do
+ let(:song_id) { 294_649 }
+
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.songs(token: nil, song_id: song_id)).to be_nil
+ end
+ end
+ end
+
+ describe '.get_lyrics' do
+ let(:song_id) { 294_649 }
+ let(:html_body) do
+ <<~HTML
+
+
+
+
Test lyrics content
+
+
+
+ HTML
+ end
+
+ before { allow(HTTParty).to receive(:get).and_return(html_body) }
+
+ it 'extracts lyrics from the page' do
+ result = described_class.get_lyrics(song_id)
+ expect(result).to eq('Test lyrics content')
+ end
+
+ context 'with nil song_id' do
+ it 'raises ArgumentError' do
+ expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError)
+ end
+ end
+ end
+end
diff --git a/spec/api/web_pages_spec.rb b/spec/api/web_pages_spec.rb
new file mode 100644
index 0000000..b2433ab
--- /dev/null
+++ b/spec/api/web_pages_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'genius/api'
+
+describe Genius::WebPages do
+ describe '.lookup' do
+ context 'with valid token' do
+ let(:token) { 'a' * 64 }
+ let(:response_body) do
+ {
+ 'meta' => { 'status' => 200 },
+ 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } }
+ }.to_json
+ end
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:get).and_return(mock_response)
+ end
+
+ it 'returns web page data as a hash' do
+ result = described_class.lookup(token: token)
+ aggregate_failures do
+ expect(result).to be_a(Hash)
+ expect(result.dig('meta', 'status')).to eq(200)
+ end
+ end
+
+ it 'sends a GET request to the API root' do
+ described_class.lookup(token: token)
+ expect(HTTParty).to have_received(:get).with(
+ "https://api.genius.com/?access_token=#{token}"
+ )
+ end
+ end
+
+ context 'with URL variants' do
+ let(:token) { 'a' * 64 }
+ let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } }.to_json }
+ let(:mock_response) { instance_double(HTTParty::Response, body: response_body) }
+ let(:url_options) do
+ {
+ raw_annotatable_url: 'https://example.com/page',
+ canonical_url: 'https://example.com/canonical',
+ og_url: 'https://example.com/og'
+ }
+ end
+ let(:expected_url) do
+ "https://api.genius.com/?access_token=#{token}" \
+ '&raw_annotatable_url=https://example.com/page' \
+ '&canonical_url=https://example.com/canonical' \
+ '&og_url=https://example.com/og'
+ end
+
+ before do
+ allow(Genius::Errors).to receive(:validate_token)
+ allow(HTTParty).to receive(:get).and_return(mock_response)
+ end
+
+ it 'includes query parameters in the request' do
+ described_class.lookup(token: token, options: url_options)
+ expect(HTTParty).to have_received(:get).with(expected_url)
+ end
+ end
+
+ context 'without token' do
+ before do
+ allow(Genius::Auth).to receive(:authorized?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(described_class.lookup(token: nil)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/extensions/hash_spec.rb b/spec/extensions/hash_spec.rb
index 86041f4..6b22045 100644
--- a/spec/extensions/hash_spec.rb
+++ b/spec/extensions/hash_spec.rb
@@ -1,45 +1,86 @@
# frozen_string_literal: true
-require "rspec"
-require "extensions/extensions"
+require 'extensions/deep_find'
describe Hash do
- describe "#deep_find" do
- let(:musicians) do
- { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] },
- "Adele" => { "19" => ["Day Dreamer", "Best for Last"] },
- "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } }
+ describe '#deep_find' do
+ context 'with basic lookup' do
+ subject(:hash) do
+ {
+ key1: 'value1',
+ key2: {
+ nested_key: 'nested_value',
+ key3: {
+ deep_key: 'deep_value'
+ }
+ }
+ }
+ end
+
+ it 'finds a value at the top level' do
+ expect(hash.deep_find(:key1)).to eq('value1')
+ end
+
+ it 'finds a value at a nested level' do
+ expect(hash.deep_find(:nested_key)).to eq('nested_value')
+ end
+
+ it 'finds a value at a deep level' do
+ expect(hash.deep_find(:deep_key)).to eq('deep_value')
+ end
+
+ it 'returns nil for missing keys' do
+ expect(hash.deep_find(:nonexistent)).to be_nil
+ end
+ end
+
+ context 'with array containing hashes' do
+ subject(:hash) do
+ {
+ items: [
+ { id: 1, name: 'foo' },
+ { id: 2, name: 'bar' }
+ ]
+ }
+ end
+
+ it 'finds values inside arrays of hashes' do
+ expect(hash.deep_find(:name)).to contain_exactly('foo', 'bar')
+ end
end
- let(:new_hash) do
- { "a" => "b", "c" => { "a" => "b" } }
+
+ context 'with nil values' do
+ it 'returns nil' do
+ expect({ a: nil }.deep_find(:a)).to be_nil
+ end
end
- context "when the option uniq is set to true" do
- it "returns an array of unique values for the given key" do
- expect(musicians.deep_find("19", uniq: true)).to eq(["Day Dreamer", "Best for Last"])
- expect(musicians.deep_find("28",
- uniq: true)).to eq([["Highest in the Room", "Franchise"],
- ["Shape of You", "Castle on the Hill"]])
- expect(new_hash.deep_find("a", uniq: true)).to eq("b")
+ context 'with array' do
+ subject(:hash) { { key: [{ nested: 'value' }] } }
+
+ it 'returns match for nil target' do
+ expect(hash.deep_find(:nested)).to eq('value')
end
end
- context "when the option uniq is set to false" do
- it "returns an array of all values for the given key, including duplicates" do
- expect(musicians.deep_find("19", uniq: false)).to eq(["Day Dreamer", "Best for Last"])
- expect(musicians.deep_find("28",
- uniq: false)).to eq([["Highest in the Room", "Franchise"],
- ["Shape of You", "Castle on the Hill"]])
- expect(new_hash.deep_find("a", uniq: false)).to eq(%w[b b])
+ context 'with integer key' do
+ subject(:hash) { { 1 => 'one', 2 => 'two' } }
+
+ it 'finds integer keys' do
+ expect(hash.deep_find(1)).to eq('one')
end
end
- it "returns the value of the given key if it exists in the top level of the hash" do
- expect(musicians.deep_find("Adele")).to eq({ "19" => ["Day Dreamer", "Best for Last"] })
+ context 'with empty hash' do
+ it 'returns nil' do
+ expect({}.deep_find(:anything)).to be_nil
+ end
end
- it "returns nil if the given key does not exist in the hash" do
- expect(musicians.deep_find("30")).to eq []
+ context 'with empty array' do
+ it 'returns nil' do
+ expect({ items: [] }.deep_find(:anything)).to be_nil
+ end
end
end
end
diff --git a/spec/extensions/options_helper_spec.rb b/spec/extensions/options_helper_spec.rb
new file mode 100644
index 0000000..11c7e66
--- /dev/null
+++ b/spec/extensions/options_helper_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'extensions/extensions'
+
+describe Object do
+ describe '#options_helper' do
+ subject(:helper) { described_class.new }
+
+ let(:allowed_keys) { %i[sort per_page page] }
+
+ context 'when options contain allowed keys' do
+ it 'builds a query string from allowed options' do
+ result = helper.options_helper({ sort: 'title', per_page: 10, page: 1 }, allowed_keys)
+ expect(result).to eq('&sort=title&per_page=10&page=1')
+ end
+ end
+
+ context 'when options contain disallowed keys' do
+ it 'ignores disallowed keys' do
+ result = helper.options_helper({ sort: 'title', foo: 'bar' }, allowed_keys)
+ expect(result).to eq('&sort=title')
+ end
+ end
+
+ context 'when options is empty' do
+ it 'returns an empty string' do
+ result = helper.options_helper({}, allowed_keys)
+ expect(result).to eq('')
+ end
+ end
+ end
+end
diff --git a/spec/extensions/token_ext_spec.rb b/spec/extensions/token_ext_spec.rb
new file mode 100644
index 0000000..0e9dd41
--- /dev/null
+++ b/spec/extensions/token_ext_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'extensions/extensions'
+
+describe Object do
+ describe '#token_ext' do
+ subject(:helper) { described_class.new }
+
+ let(:token) { 'a' * 64 }
+
+ context 'when a token is provided' do
+ it 'returns the token' do
+ expect(helper.token_ext(token)).to eq(token)
+ end
+ end
+
+ context 'when token is nil and Genius::Auth has an instance variable @token' do
+ before do
+ Genius::Auth.instance_variable_set(:@token, token)
+ end
+
+ after do
+ Genius::Auth.instance_variable_set(:@token, nil)
+ end
+
+ it 'returns the stored token' do
+ expect(helper.token_ext(nil)).to eq(token)
+ end
+ end
+ end
+end
diff --git a/spec/extensions/unescape_spec.rb b/spec/extensions/unescape_spec.rb
new file mode 100644
index 0000000..2bda3a7
--- /dev/null
+++ b/spec/extensions/unescape_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rspec'
+require 'extensions/extensions'
+
+describe String do
+ describe '#unescape' do
+ it 'unescapes JSON-escaped double quotes' do
+ str = 'test \\"string\\"'
+ expect(str.unescape).to eq('test "string"')
+ end
+
+ it 'handles strings without escaped characters' do
+ str = 'hello world'
+ expect(str.unescape).to eq('hello world')
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 398d9b0..68b44f9 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-$LOAD_PATH << "./genius"
+$LOAD_PATH << './genius'
# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause