diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..06f8b90 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,6 @@ +FROM ruby:latest + +WORKDIR /usr/src/app + +COPY . . +RUN bundle install diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bca8d96 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "twurl dev container", + "dockerComposeFile": "./docker-compose.yml", + "service": "twurl", + "workspaceFolder": "/usr/src/app", + "settings": { + "terminal.integrated.profiles.linux": { + "bash": { + "path": "bash", + "args": ["-l"] + } + } + }, + "extensions": [ + "rebornix.Ruby" + ] +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..652a66a --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3" +services: + twurl: + build: + context: .. + dockerfile: ./.devcontainer/Dockerfile + command: /bin/sh -c "while sleep 1000; do :; done" + volumes: + - ../:/usr/src/app diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index ef3b9bc..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ -One line summary of the issue here. - -### Expected behavior - -As concisely as possible, describe the expected behavior. - -### Actual behavior - -As concisely as possible, describe the observed behavior. - -### Steps to reproduce the behavior - -Please list all relevant steps to reproduce the observed behavior. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c6fed7f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question about the Twitter API? Ask the Twitter Developer Community + url: https://twittercommunity.com/ + about: For general API functionality questions, please ask in the developer forums. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..201865c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + - push + - pull_request + +jobs: + test: + strategy: + fail-fast: false + matrix: + ruby: + - '2.5' + - '2.6' + - '2.7' + - '3.0' + - '3.1' + - 'ruby-head' + - 'jruby-head' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run unit tests + run: bundle exec rake diff --git a/.github/workflows/cla.ympython b/.github/workflows/cla.ympython new file mode 100644 index 0000000..b5eac6a --- /dev/null +++ b/.github/workflows/cla.ympython @@ -0,0 +1,25 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened,closed,synchronize] + +jobs: + CLAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Alpha Release + uses: cla-assistant/github-action@v2.0.2-alpha + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN : ${{ secrets.CLA_PAT }} + with: + remote-organization-name: twitter + remote-repository-name: .github-private + path-to-signatures: 'cla/signatures.json' + path-to-document: 'https://gist.github.com/twitter-service/a1ad5818c024dc4265f8b60e6d043f26' + custom-allsigned-prcomment: 'All Contributors have signed the CLA. If the commit check is not passing, a maintainer must go the Checks tab of this PR and rerun the GitHub Action.' + branch: 'main' diff --git a/.gitignore b/.gitignore index 5ee79fe..43b06ca 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ test/version_tmp tmp tmtags tramp +.vscode +vendor \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index dfd89ff..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -bundler_args: --without development -language: ruby -rvm: - - 1.9.3 - - 2.0.0 - - jruby-head - - rbx-2 - - ruby-head -matrix: - allow_failures: - - rvm: jruby-head - - rvm: ruby-head - fast_finish: true -sudo: false diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..62347b8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,48 @@ +# Code of Conduct v2.0 + +This code of conduct outlines our expectations for participants within the [@TwitterOSS](https://twitter.com/twitteross) community, as well as steps to reporting unacceptable behavior. We are committed to providing a welcoming and inspiring community for all and expect our code of conduct to be honored. Anyone who violates this code of conduct may be banned from the community. + +Our open source community strives to: + +* **Be friendly and patient.** +* **Be welcoming**: We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. +* **Be considerate**: Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. +* **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. +* **Be careful in the words that you choose**: we are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: + * Violent threats or language directed against another person. + * Discriminatory jokes and language. + * Posting sexually explicit or violent material. + * Posting (or threatening to post) other people's personally identifying information ("doxing"). + * Personal insults, especially those using racist or sexist terms. + * Unwelcome sexual attention. + * Advocating for, or encouraging, any of the above behavior. + * Repeated harassment of others. In general, if someone asks you to stop, then stop. +* **When we disagree, try to understand why**: Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of our community comes from its diversity, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. + +This code is not exhaustive or complete. It serves to distill our common understanding of a collaborative, shared environment, and goals. We expect it to be followed in spirit as much as in the letter. + +### Diversity Statement + +We encourage everyone to participate and are committed to building a community for all. Although we may not be able to satisfy everyone, we all agree that everyone is equal. Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do our best to right the wrong. + +Although this list cannot be exhaustive, we explicitly honor diversity in age, gender, gender identity or expression, culture, ethnicity, language, national origin, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate discrimination based on any of the protected +characteristics above, including participants with disabilities. + +### Reporting Issues + +If you experience or witness unacceptable behavior—or have any other concerns—please report it by contacting us via [opensource+codeofconduct@twitter.com](mailto:opensource+codeofconduct@twitter.com). All reports will be handled with discretion. In your report please include: + +- Your contact information. +- Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional witnesses, please +include them as well. Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public IRC logger), please include a link. +- Any additional information that may be helpful. + +After filing a report, a representative will contact you personally. If the person who is harassing you is part of the response team, they will recuse themselves from handling your incident. A representative will then review the incident, follow up with any additional questions, and make a decision as to how to respond. We will respect confidentiality requests for the purpose of protecting victims of abuse. + +Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual engages in unacceptable behavior, the representative may take any action they deem appropriate, up to and including a permanent ban from our community without warning. + +## Thanks + +This code of conduct is based on the [Open Code of Conduct](https://github.com/todogroup/opencodeofconduct) from the [TODOGroup](http://todogroup.org). + +We are thankful for their work and all the communities who have paved the way with code of conducts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c38fa06 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# How to Contribute + +We'd love to get patches from you! + +## Getting Started + +We follow the [GitHub Flow Workflow](https://guides.github.com/introduction/flow/) + +1. Fork the project +2. Check out the `master` branch +3. Create a feature branch +4. Write code and tests for your change + +```sh +$ cd ./twurl + +# install twurl from source +$ bundle install + +# run twurl from source +$ bundle exec twurl -v + +# run tests +$ bundle exec rake +``` + +5. From your branch, make a pull request against `twitter/twurl/master` +6. Work with repo maintainers to get your change reviewed +7. Wait for your change to be pulled into `twitter/twurl/master` +8. Delete your feature branch + +## License + +By contributing your code, you agree to license your contribution under the +terms of the MIT License: https://github.com/twitter/twurl/blob/master/LICENSE + +## Code of Conduct + +Read our [Code of Conduct](CODE_OF_CONDUCT.md) for the project. diff --git a/COPYING b/COPYING deleted file mode 100644 index 04dc088..0000000 --- a/COPYING +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2009 Marcel Molina , Twitter, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in the -# Software without restriction, including without limitation the rights to use, -# copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -# Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -# AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Gemfile b/Gemfile index 9534487..98251a3 100644 --- a/Gemfile +++ b/Gemfile @@ -4,9 +4,8 @@ gem 'jruby-openssl', :platforms => :jruby gem 'rake' group :test do - gem 'coveralls' gem 'minitest', '>= 5' - gem 'rr', '>= 1.1' + gem 'rr', '~> 3.0.9' gem 'simplecov', '>= 0.9' end diff --git a/INSTALL b/INSTALL deleted file mode 100644 index 106127f..0000000 --- a/INSTALL +++ /dev/null @@ -1,23 +0,0 @@ -+-----------------------+ -| Install with RubyGems | -+-----------------------+ - - sudo gem i twurl --source http://rubygems.org - -+---------------------+ -| Build from source | -+---------------------+ - - rake build - -+---------------------+ -| Install from source | -+---------------------+ - - rake install - -+--------------+ -| Dependencies | -+--------------+ - - sudo gem i oauth diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..961ed5c --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,36 @@ +# Install + +## Install with RubyGems (recommended) + +```sh +# installing the latest release +$ gem install twurl +``` + +```sh +# verify installation +$ twurl -v +0.9.7 +``` + +## Install from source + +In case if you haven't installed `bundler` you need to install it first: + +```sh +$ gem install bundler +``` + +```sh +$ git clone https://github.com/twitter/twurl +$ cd twurl +$ bundle install +``` + +If you don't want to install Twurl globally on your system, use `--path` [option](https://bundler.io/v2.0/bundle_install.html): + +``` +$ bundle install --path path/to/directory +$ bundle exec twurl -v +0.9.7 +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59d11d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2009 Marcel Molina , 2019 Twitter, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1fdb5fb..fa9f0fc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ -Twurl -===== +# Twurl + +[![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/twitter/twurl/blob/master/LICENSE) +[![Gem Version](https://badge.fury.io/rb/twurl.svg)](https://badge.fury.io/rb/twurl) +[![CI](https://github.com/twitter/twurl/actions/workflows/ci.yml/badge.svg)](https://github.com/twitter/twurl/actions/workflows/ci.yml) Twurl is like curl, but tailored specifically for the Twitter API. It knows how to grant an access token to a client application for @@ -10,34 +13,29 @@ as defining aliases for common requests, as well as support for multiple access tokens to easily switch between different client applications and Twitter accounts. +## Installing Twurl -Installing Twurl ----------------- - -Twurl can be installed using ruby gems: +Twurl can be installed using RubyGems: -``` +```sh gem install twurl ``` +## Getting Started -Getting Started ---------------- +If you haven't already, the first thing to do is apply for a developer account to access Twitter APIs: -If you haven't already, the first thing to do is apply for a developer account to access Twitter APIs: - -``` +```text https://developer.twitter.com/en/apply-for-access ``` - -After you have that access you can create a Twitter app and generate a consumer key and secret. - + +After you have that access you can create a Twitter app and generate a consumer key and secret. When you have your consumer key and its secret you authorize -your Twitter account to make API requests with your consumer key +your Twitter account to make API requests with that consumer key and secret. -``` +```sh twurl authorize --consumer-key key \ --consumer-secret secret ``` @@ -47,14 +45,12 @@ Authenticate to Twitter, and then enter the returned PIN back into the terminal. Assuming all that works well, you will be authorized to make requests with the API. Twurl will tell you as much. - -Making Requests ---------------- +## Making Requests The simplest request just requires that you specify the path you want to request. -``` +```sh twurl /1.1/statuses/home_timeline.json ``` @@ -63,47 +59,80 @@ Similar to curl, a GET request is performed by default. You can implicitly perform a POST request by passing the -d option, which specifies POST parameters. -``` +```sh twurl -d 'status=Testing twurl' /1.1/statuses/update.json ``` You can explicitly specify what request method to perform with the -X (or --request-method) option. -``` +```sh twurl -X POST /1.1/statuses/destroy/1234567890.json ``` +## Using Bearer Tokens (Application-only authentication) + +You can generate a bearer token using `--bearer` option in combination with the `authorize` subcommand. + +```sh +twurl authorize --bearer --consumer-key key \ + --consumer-secret secret +``` + +And then, you can make a request using a generated bearer token using `--bearer` request option. + +```sh +twurl --bearer '/1.1/search/tweets.json?q=hello' +``` + +To list your generated bearer tokens, you can use the `bearer_tokens` subcommand. + +```sh +twurl bearer_tokens +``` + +This will print a pair of consumer_key and its associated bearer token. Note, tokens are omitted from this output. + +## Accessing Different Hosts + +You can access different hosts for other Twitter APIs using the -H flag. + +```sh +twurl -H "ads-api.twitter.com" "/7/accounts" +``` + +## Uploading Media -Creating aliases ----------------- +To upload binary files, you can format the call as a form post. Below, the binary is "/path/to/media.jpg" and the form field is "media": +```sh +twurl -H "upload.twitter.com" -X POST "/1.1/media/upload.json" --file "/path/to/media.jpg" --file-field "media" ``` + +## Creating aliases + +```sh twurl alias h /1.1/statuses/home_timeline.json ``` You can then use "h" in place of the full path. -``` +```sh twurl h ``` -Paths that require additional options such as request parameters for example can -be used with aliases the same as with full explicit paths, just as you might -expect. +Paths that require additional options (such as request parameters, for example) can be used with aliases the same as with full explicit paths, just as you might expect. -``` +```sh twurl alias tweet /1.1/statuses/update.json twurl tweet -d "status=Aliases in twurl are convenient" ``` +## Changing your default profile -Changing your default profile ------------------------------ - -The first time you authorize a client application to make requests on behalf of your account, twurl stores your access token information in its .twurlrc file. Subsequent requests will use this profile as the default profile. You can use the 'accounts' subcommand to see what client applications have been authorized for what user names: +The first time you authorize a client application to make requests on behalf of your account, twurl stores your access token information in its `~/.twurlrc` file. Subsequent requests will use this profile as the default profile. You can use the `accounts` subcommand to see what client applications have been authorized for what user names: -``` +```sh twurl accounts noradio HQsAGcBm5MQT4n6j7qVJw @@ -112,9 +141,9 @@ twurl accounts guT9RsJbNQgVe6AwoY9BA ``` -Notice that one of those consumer keys is marked as the default. To change the default use the 'set' subcommand, passing then either just the username, if it's unambiguous, or the username and consumer key pair if it isn't unambiguous: +Notice that one of those consumer keys is marked as the default. To change the default use the `set` subcommand, passing then either just the username, if it's unambiguous, or the username and consumer key pair if it isn't unambiguous: -``` +```sh twurl set default testiverse twurl accounts noradio @@ -124,7 +153,7 @@ twurl accounts guT9RsJbNQgVe6AwoY9BA (default) ``` -``` +```sh twurl set default noradio HQsAGcBm5MQT4n6j7qVJw twurl accounts noradio @@ -134,8 +163,14 @@ twurl accounts guT9RsJbNQgVe6AwoY9BA ``` -Contributors ------------- +### Profiles and Bearer Tokens + +While changing the default profile allows you to select which access token (OAuth1.0a) to use, bearer tokens don't link to any user profiles as the Application-only authentication doesn't require user context. That is, you can make an application-only request regardless of your default profile if you specify the `-c` (`--consumer-key`) option once you generate a bearer token with this consumer key. By default, twurl reads the current profile's consumer key and its associated bearer token from `~/.twurlrc` file. + +## Contributors + +Marcel Molina / @noradio -Marcel Molina / @noradio Erik Michaels-Ober / @sferik + +and there are many [more](https://github.com/twitter/twurl/graphs/contributors)! diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..cc35c1d --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-modernist \ No newline at end of file diff --git a/lib/twurl.rb b/lib/twurl.rb index 124a2a0..0f50089 100644 --- a/lib/twurl.rb +++ b/lib/twurl.rb @@ -4,6 +4,7 @@ require 'ostruct' require 'stringio' require 'yaml' +require 'json' library_files = Dir[File.join(File.dirname(__FILE__), "/twurl/**/*.rb")].sort library_files.each do |file| diff --git a/lib/twurl/aliases_controller.rb b/lib/twurl/aliases_controller.rb index 6d9ebc0..4dd6b96 100644 --- a/lib/twurl/aliases_controller.rb +++ b/lib/twurl/aliases_controller.rb @@ -15,9 +15,13 @@ def dispatch end when 1 if options.path - OAuthClient.rcfile.alias(options.subcommands.first, options.path) + if Twurl::CLI::SUPPORTED_COMMANDS.include?(options.subcommands.first) + raise Exception, "ERROR: '#{options.subcommands.first}' is reserved for commands. Please use different alias name." + else + OAuthClient.rcfile.alias(options.subcommands.first, options.path) + end else - CLI.puts NO_PATH_PROVIDED_MESSAGE + raise Exception, NO_PATH_PROVIDED_MESSAGE end end end diff --git a/lib/twurl/app_only_oauth_client.rb b/lib/twurl/app_only_oauth_client.rb new file mode 100644 index 0000000..bd3133f --- /dev/null +++ b/lib/twurl/app_only_oauth_client.rb @@ -0,0 +1,75 @@ +require 'base64' +require_relative 'oauth_client' + +module Twurl + class AppOnlyOAuthClient < Twurl::OAuthClient + + AUTHORIZATION_FAILED_MESSAGE = "Authorization failed. Check that your consumer key and secret are correct." + + attr_reader :consumer_key, :consumer_secret, :bearer_token + + def initialize(options = {}) + @consumer_key = options['consumer_key'] + @consumer_secret = options['consumer_secret'] + @bearer_token = options['bearer_token'] + end + + def save + self.class.rcfile.bearer_token(consumer_key, bearer_token) + end + + def exchange_credentials_for_access_token + response = fetch_oauth2_token + if response.nil? || response[:access_token].nil? + raise Exception, AUTHORIZATION_FAILED_MESSAGE + end + @bearer_token = response[:access_token] + end + + def perform_request_from_options(options, &block) + request = build_request_from_options(options) + request['user-agent'] = user_agent + request['authorization'] = "Bearer #{bearer_token}" + + http_client.request(request, &block) + end + + def needs_to_authorize? + bearer_token.nil? + end + + def request_data + {'grant_type' => 'client_credentials'} + end + + def http_client + uri = URI.parse(Twurl.options.base_url) + http = if Twurl.options.proxy + proxy_uri = URI.parse(Twurl.options.proxy) + Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port) + else + Net::HTTP.new(uri.host, uri.port) + end + set_http_client_options(http) + end + + def set_http_client_options(http) + http.set_debug_output(Twurl.options.debug_output_io) if Twurl.options.trace + http.read_timeout = http.open_timeout = Twurl.options.timeout || 60 + http.open_timeout = Twurl.options.connection_timeout if Twurl.options.connection_timeout + http.max_retries = 0 + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http + end + + def fetch_oauth2_token + request = Net::HTTP::Post.new('/oauth2/token') + request.body = URI.encode_www_form(request_data) + request['user-agent'] = user_agent + request['authorization'] = "Basic #{Base64.strict_encode64("#{consumer_key}:#{consumer_secret}")}" + response = http_client.request(request).body + JSON.parse(response,:symbolize_names => true) + end + end +end diff --git a/lib/twurl/app_only_token_information_controller.rb b/lib/twurl/app_only_token_information_controller.rb new file mode 100644 index 0000000..333845e --- /dev/null +++ b/lib/twurl/app_only_token_information_controller.rb @@ -0,0 +1,18 @@ +module Twurl + class AppOnlyTokenInformationController < AbstractCommandController + NO_ISSUED_TOKENS_MESSAGE = "No issued application-only (Bearer) tokens" + + def dispatch + rcfile = OAuthClient.rcfile + if rcfile.empty? || rcfile.bearer_tokens.nil? + CLI.puts NO_ISSUED_TOKENS_MESSAGE + else + tokens = rcfile.bearer_tokens + CLI.puts "[consumer_key: bearer_token]" + tokens.each_key do |consumer_key| + CLI.puts "#{consumer_key}: (omitted)" + end + end + end + end +end diff --git a/lib/twurl/cli.rb b/lib/twurl/cli.rb index c68210c..ad26a0a 100644 --- a/lib/twurl/cli.rb +++ b/lib/twurl/cli.rb @@ -1,13 +1,11 @@ module Twurl class CLI - SUPPORTED_COMMANDS = %w(authorize accounts alias set) + SUPPORTED_COMMANDS = %w(authorize accounts bearer_tokens alias set) DEFAULT_COMMAND = 'request' PATH_PATTERN = /^\/\w+/ PROTOCOL_PATTERN = /^\w+:\/\// README = File.dirname(__FILE__) + '/../../README.md' @output ||= STDOUT - class NoPathFound < Exception - end class << self attr_accessor :output @@ -15,8 +13,8 @@ class << self def run(args) begin options = parse_options(args) - rescue NoPathFound => e - exit + rescue Twurl::Exception => exception + abort(exception.message) end dispatch(options) end @@ -28,6 +26,8 @@ def dispatch(options) AuthorizationController when 'accounts' AccountInformationController + when 'bearer_tokens' + AppOnlyTokenInformationController when 'alias' AliasesController when 'set' @@ -41,9 +41,8 @@ def dispatch(options) end def parse_options(args) - arguments = args.dup - Twurl.options = Options.new + Twurl.options.args = args.dup Twurl.options.trace = false Twurl.options.data = {} Twurl.options.headers = {} @@ -66,7 +65,6 @@ def parse_options(args) o.section "Authorization options:" do username - password consumer_key consumer_secret access_token @@ -80,7 +78,6 @@ def parse_options(args) headers host quiet - disable_ssl request_method help version @@ -88,19 +85,31 @@ def parse_options(args) file filefield base64 + json_format + timeout + connection_timeout + app_only end end - arguments = option_parser.parse!(args) + begin + arguments = option_parser.parse!(args) + rescue OptionParser::InvalidOption + raise Exception "ERROR: undefined option" + rescue Twurl::Exception + raise + rescue + raise Exception "ERROR: invalid argument" + end Twurl.options.command = extract_command!(arguments) Twurl.options.path = extract_path!(arguments) - - if Twurl.options.command == DEFAULT_COMMAND and Twurl.options.path.nil? + Twurl.options.subcommands = arguments + + if Twurl.options.command == DEFAULT_COMMAND and Twurl.options.path.nil? and Twurl.options.args.empty? CLI.puts option_parser - raise NoPathFound, "No path found" + raise Exception, "No path found" end - Twurl.options.subcommands = arguments Twurl.options end @@ -159,11 +168,9 @@ def extract_path!(arguments) end def escape_params(params) - split_params = params.split("&").map do |param| - key, value = param.split('=', 2) - CGI::escape(key) + '=' + CGI::escape(value) - end - split_params.join("&") + CGI::parse(params).map do |key, value| + "#{CGI.escape(key)}=#{CGI.escape(value.first)}" + end.join("&") end end @@ -216,12 +223,6 @@ def username end end - def password - on('-p', '--password [password]', 'Password of account to authorize (required)') do |password| - options.password = password ? password : CLI.prompt_for('Password') - end - end - def trace on('-t', '--[no-]trace', 'Trace request/response traffic (default: --no-trace)') do |trace| options.trace = trace @@ -230,18 +231,26 @@ def trace def data on('-d', '--data [data]', 'Sends the specified data in a POST request to the HTTP server.') do |data| - data.split('&').each do |pair| - key, value = pair.split('=', 2) - options.data[key] = value + if options.args.count { |item| /^content-type:\s+application\/json/i.match(item) } > 0 + options.json_data = true + options.data = data + else + CGI.parse(data).each_pair do |key, value| + options.data[key] = value.first + end end end end def raw_data on('-r', '--raw-data [data]', 'Sends the specified data as it is in a POST request to the HTTP server.') do |data| - CGI::parse(data).each_pair do |key, value| - options.data[key] = value.first + if options.raw_data + raise Exception, "ERROR: can't specify '-r' option more than once" + elsif options.args.include?('-d') || options.args.include?('--data') + raise Exception, "ERROR: can't use '-r' and '-d' options together" end + options.raw_data = true + options.data = data end end @@ -269,12 +278,6 @@ def quiet end end - def disable_ssl - on('-U', '--no-ssl', 'Disable SSL (default: SSL is enabled)') do |use_ssl| - options.protocol = 'http' - end - end - def request_method on('-X', '--request-method [method]', 'Request method (default: GET)') do |request_method| options.request_method = request_method.downcase @@ -290,7 +293,7 @@ def help def version on_tail("-v", "--version", "Show version") do - CLI.puts Version + CLI.puts "twurl version: #{Version}\nplatform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" exit end end @@ -323,6 +326,30 @@ def base64 options.upload['base64'] = base64 end end + + def json_format + on('-j', '--json-pretty', 'Format response body to JSON pretty style') do |json_format| + options.json_format = true + end + end + + def timeout + on('--timeout [sec]', Integer, 'Number of seconds to wait for the request to be read (default: 60)') do |timeout| + options.timeout = timeout + end + end + + def connection_timeout + on('--connection-timeout [sec]', Integer, 'Number of seconds to wait for the connection to open (default: 60)') do |connection_timeout| + options.connection_timeout = connection_timeout + end + end + + def app_only + on('--bearer', "Use application-only authentication (Bearer Token)") do |app_only| + options.app_only = true + end + end end end @@ -342,10 +369,6 @@ def base_url "#{protocol}://#{host}" end - def ssl? - protocol == 'https' - end - def debug_output_io super || STDERR end diff --git a/lib/twurl/oauth_client.rb b/lib/twurl/oauth_client.rb index 773e333..c87226b 100644 --- a/lib/twurl/oauth_client.rb +++ b/lib/twurl/oauth_client.rb @@ -9,15 +9,27 @@ def rcfile(reload = false) end def load_from_options(options) - if rcfile.has_oauth_profile_for_username_with_consumer_key?(options.username, options.consumer_key) + if options.command == 'request' && has_oauth_options?(options) + load_new_client_from_oauth_options(options) + elsif options.command == 'request' && options.app_only && options.consumer_key + load_client_for_non_profile_app_only_auth(options) + elsif rcfile.has_oauth_profile_for_username_with_consumer_key?(options.username, options.consumer_key) load_client_for_username_and_consumer_key(options.username, options.consumer_key) - elsif options.username || (options.command == 'authorize') + elsif options.username + load_client_for_username(options.username) + elsif options.command == 'authorize' && options.app_only + load_client_for_app_only_auth(options, options.consumer_key) + elsif options.command == 'authorize' load_new_client_from_options(options) else - load_default_client + load_default_client(options) end end + def has_oauth_options?(options) + (options.consumer_key && options.consumer_secret && options.access_token && options.token_secret) ? true : false + end + def load_client_for_username_and_consumer_key(username, consumer_key) user_profiles = rcfile[username] if user_profiles && attributes = user_profiles[consumer_key] @@ -40,21 +52,63 @@ def load_client_for_username(username) end def load_new_client_from_options(options) - new(options.oauth_client_options.merge('password' => options.password)) + new(options.oauth_client_options) + end + + def load_new_client_from_oauth_options(options) + new(options.oauth_client_options.merge( + 'token' => options.access_token, + 'secret' => options.token_secret + ) + ) + end + + def load_client_for_app_only_auth(options, consumer_key) + if options.command == 'authorize' + AppOnlyOAuthClient.new(options) + else + AppOnlyOAuthClient.new( + options.oauth_client_options.merge( + 'bearer_token' => rcfile.bearer_tokens.to_hash[consumer_key] + ) + ) + end end - def load_default_client - raise Exception, "You must authorize first" unless rcfile.default_profile - load_client_for_username_and_consumer_key(*rcfile.default_profile) + def load_client_for_non_profile_app_only_auth(options) + AppOnlyOAuthClient.new( + options.oauth_client_options.merge( + 'bearer_token' => rcfile.bearer_tokens.to_hash[options.consumer_key] + ) + ) + end + + def load_default_client(options) + return if options.command == 'bearer_tokens' + + exception_message = "You must authorize first." + app_only_exception_message = "To use --bearer option, you need to authorize (OAuth1.0a) and create at least one user profile (~/.twurlrc):\n\n" \ + "twurl authorize -c key -s secret\n" \ + "\nor, you can specify issued token's consumer_key directly:\n" \ + "(to see your issued tokens: 'twurl bearer_tokens')\n\n" \ + "twurl --bearer -c key '/path/to/api'" + + raise Exception, "#{options.app_only ? app_only_exception_message : exception_message}" unless rcfile.default_profile + if options.app_only + raise Exception, "No available bearer token found for consumer_key:#{rcfile.default_profile_consumer_key}" \ + unless rcfile.has_bearer_token_for_consumer_key?(rcfile.default_profile_consumer_key) + load_client_for_app_only_auth(options, rcfile.default_profile_consumer_key) + else + load_client_for_username_and_consumer_key(*rcfile.default_profile) + end end end OAUTH_CLIENT_OPTIONS = %w[username consumer_key consumer_secret token secret] attr_reader *OAUTH_CLIENT_OPTIONS - attr_reader :username, :password + attr_reader :username def initialize(options = {}) @username = options['username'] - @password = options['password'] @consumer_key = options['consumer_key'] @consumer_secret = options['consumer_secret'] @token = options['token'] @@ -72,7 +126,7 @@ def initialize(options = {}) :copy => Net::HTTP::Copy } - def perform_request_from_options(options, &block) + def build_request_from_options(options, &block) request_class = METHODS.fetch(options.request_method.to_sym) request = request_class.new(options.path, options.headers) @@ -97,10 +151,10 @@ def perform_request_from_options(options, &block) multipart_body << "\r\n" if options.upload['base64'] - enc = Base64.encode64(File.read(filename)) + enc = Base64.encode64(File.binread(filename)) multipart_body << enc else - multipart_body << File.read(filename) + multipart_body << File.binread(filename) end } @@ -108,19 +162,40 @@ def perform_request_from_options(options, &block) request.body = multipart_body.join request.content_type = "multipart/form-data, boundary=\"#{boundary}\"" - elsif request.content_type && options.data - request.body = options.data.keys.first + elsif options.json_data + request.body = options.data elsif options.data - request.set_form_data(options.data) + request.content_type = "application/x-www-form-urlencoded" unless request.content_type + if options.raw_data + request.body = options.data + else + begin + request.body = options.data.map do |key, value| + "#{key}" + (value.nil? ? "" : "=#{CGI.escape(value)}") + end.join("&") + rescue + raise Exception, "ERROR: failed to parse POST request body" + end + end end + request + end + def perform_request_from_options(options, &block) + request = build_request_from_options(options) request.oauth!(consumer.http, consumer, access_token) + request['user-agent'] = user_agent consumer.http.request(request, &block) end + def user_agent + "twurl version: #{Version} " \ + "platform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" + end + def exchange_credentials_for_access_token response = begin - consumer.token_request(:post, consumer.access_token_path, nil, {}, client_auth_parameters) + consumer.token_request(:post, consumer.access_token_path, nil, {}) rescue OAuth::Unauthorized perform_pin_authorize_workflow end @@ -128,14 +203,14 @@ def exchange_credentials_for_access_token @secret = response[:oauth_token_secret] end - def client_auth_parameters - {'x_auth_username' => username, 'x_auth_password' => password, 'x_auth_mode' => 'client_auth'} - end - def perform_pin_authorize_workflow @request_token = consumer.get_request_token CLI.puts("Go to #{generate_authorize_url} and paste in the supplied PIN") - pin = gets + begin + pin = STDIN.gets.chomp + rescue SystemExit, Interrupt + raise Exception, "Operation cancelled" + end access_token = @request_token.get_access_token(:oauth_verifier => pin.chomp) {:oauth_token => access_token.token, :oauth_token_secret => access_token.secret} end @@ -145,7 +220,7 @@ def generate_authorize_url params = request['Authorization'].sub(/^OAuth\s+/, '').split(/,\s+/).map { |p| k, v = p.split('=') v =~ /"(.*?)"/ - "#{k}=#{CGI::escape($1)}" + "#{k}=#{CGI.escape($1)}" }.join('&') "#{Twurl.options.base_url}#{request.path}?#{params}" end @@ -191,10 +266,11 @@ def to_hash def configure_http! consumer.http.set_debug_output(Twurl.options.debug_output_io) if Twurl.options.trace - if Twurl.options.ssl? - consumer.http.use_ssl = true - consumer.http.verify_mode = OpenSSL::SSL::VERIFY_NONE - end + consumer.http.read_timeout = consumer.http.open_timeout = Twurl.options.timeout || 60 + consumer.http.open_timeout = Twurl.options.connection_timeout if Twurl.options.connection_timeout + consumer.http.max_retries = 0 + consumer.http.use_ssl = true + consumer.http.verify_mode = OpenSSL::SSL::VERIFY_NONE end def consumer diff --git a/lib/twurl/rcfile.rb b/lib/twurl/rcfile.rb index d2b50d9..d6173dd 100644 --- a/lib/twurl/rcfile.rb +++ b/lib/twurl/rcfile.rb @@ -52,6 +52,11 @@ def default_profile configuration['default_profile'] end + def default_profile_consumer_key + username, consumer_key = configuration['default_profile'] + consumer_key + end + def default_profile=(profile) configuration['default_profile'] = [profile.username, profile.consumer_key] end @@ -67,7 +72,17 @@ def alias(name, path) end def aliases - data['aliases'] + data['aliases'] ||= {} + end + + def bearer_token(consumer_key, bearer_token) + data['bearer_tokens'] ||= {} + data['bearer_tokens'][consumer_key] = bearer_token + save + end + + def bearer_tokens + data['bearer_tokens'] end def alias_from_options(options) @@ -87,6 +102,10 @@ def has_oauth_profile_for_username_with_consumer_key?(username, consumer_key) !user_profiles.nil? && !user_profiles[consumer_key].nil? end + def has_bearer_token_for_consumer_key?(consumer_key) + bearer_tokens.nil? ? false : bearer_tokens.to_hash.has_key?(consumer_key) + end + def <<(oauth_client) client_from_file = self[oauth_client.username] || {} client_from_file[oauth_client.consumer_key] = oauth_client.to_hash diff --git a/lib/twurl/request_controller.rb b/lib/twurl/request_controller.rb index 2b78cb8..b3e9c70 100644 --- a/lib/twurl/request_controller.rb +++ b/lib/twurl/request_controller.rb @@ -1,20 +1,41 @@ module Twurl class RequestController < AbstractCommandController - NO_URI_MESSAGE = "No URI specified" + NO_URI_MESSAGE = 'No URI specified' + INVALID_URI_MESSAGE = 'Invalid URI detected' + READ_TIMEOUT_MESSAGE = 'A timeout occurred (Net::ReadTimeout). ' \ + 'Please try again or increase the value using --timeout option.' + OPEN_TIMEOUT_MESSAGE = 'A timeout occurred (Net::OpenTimeout). ' \ + 'Please try again or increase the value using --connection-timeout option.' def dispatch if client.needs_to_authorize? raise Exception, "You need to authorize first." end options.path ||= OAuthClient.rcfile.alias_from_options(options) + raise Exception, NO_URI_MESSAGE if options.path.empty? perform_request end def perform_request client.perform_request_from_options(options) { |response| - response.read_body { |chunk| CLI.print chunk } + response.read_body { |body| + CLI.print options.json_format ? JsonFormatter.format(body) : body + } } rescue URI::InvalidURIError - CLI.puts NO_URI_MESSAGE + raise Exception, INVALID_URI_MESSAGE + rescue Net::ReadTimeout + raise Exception, READ_TIMEOUT_MESSAGE + rescue Net::OpenTimeout + raise Exception, OPEN_TIMEOUT_MESSAGE + end + end + + class JsonFormatter + def self.format(string) + json = JSON.parse(string) + (json.is_a?(Array) || json.is_a?(Hash)) ? JSON.pretty_generate(json) : string + rescue JSON::ParserError, TypeError + string end end end diff --git a/lib/twurl/version.rb b/lib/twurl/version.rb index d4593d7..69ac97f 100644 --- a/lib/twurl/version.rb +++ b/lib/twurl/version.rb @@ -2,7 +2,7 @@ module Twurl class Version MAJOR = 0 unless defined? Twurl::Version::MAJOR MINOR = 9 unless defined? Twurl::Version::MINOR - PATCH = 3 unless defined? Twurl::Version::PATCH + PATCH = 7 unless defined? Twurl::Version::PATCH PRE = nil unless defined? Twurl::Version::PRE # Time.now.to_i.to_s class << self diff --git a/test/account_information_controller_test.rb b/test/account_information_controller_test.rb index c232574..9a65be1 100644 --- a/test/account_information_controller_test.rb +++ b/test/account_information_controller_test.rb @@ -22,6 +22,7 @@ def setup @options = Twurl::Options.test_exemplar @client = Twurl::OAuthClient.load_new_client_from_options(options) mock(Twurl::OAuthClient.rcfile).save.times(1) + Twurl::OAuthClient.rcfile.profiles.clear Twurl::OAuthClient.rcfile << client @controller = Twurl::AccountInformationController.new(client, options) end @@ -51,10 +52,14 @@ def setup @controller = Twurl::AccountInformationController.new(other_client, other_client_options) end + def teardown + Twurl::OAuthClient.rcfile.profiles[default_client.username][other_client.consumer_key].clear + end + def test_authorized_account_is_displayed_and_marked_as_the_default - mock(Twurl::CLI).puts(default_client.username).times(1) - mock(Twurl::CLI).puts(" #{default_client.consumer_key} (default)").times(1) - mock(Twurl::CLI).puts(" #{other_client.consumer_key}").times(1) + mock(Twurl::CLI).puts(default_client.username).times(1).ordered + mock(Twurl::CLI).puts(" #{default_client.consumer_key} (default)").times(1).ordered + mock(Twurl::CLI).puts(" #{other_client.consumer_key}").times(1).ordered controller.dispatch end diff --git a/test/alias_controller_test.rb b/test/alias_controller_test.rb index 3dda3a1..f896e6d 100644 --- a/test/alias_controller_test.rb +++ b/test/alias_controller_test.rb @@ -41,13 +41,15 @@ def test_when_alias_and_value_are_provided_they_are_added controller.dispatch end - def test_when_no_path_is_provided_nothing_happens + def test_error_if_no_path_is_provided options.subcommands = ['a'] assert_nil options.path - mock(Twurl::CLI).puts(Twurl::AliasesController::NO_PATH_PROVIDED_MESSAGE).times(1) + e = assert_raises Twurl::Exception do + controller = Twurl::AliasesController.new(client, options) + controller.dispatch + end - controller = Twurl::AliasesController.new(client, options) - controller.dispatch + assert_equal Twurl::AliasesController::NO_PATH_PROVIDED_MESSAGE, e.message end end diff --git a/test/app_only_oauth_client_test.rb b/test/app_only_oauth_client_test.rb new file mode 100644 index 0000000..90edc37 --- /dev/null +++ b/test/app_only_oauth_client_test.rb @@ -0,0 +1,96 @@ +require File.dirname(__FILE__) + '/test_helper' + +class Twurl::AppOnlyOAuthClient::AbstractClientTest < Minitest::Test + attr_reader :client, :options + def setup + Twurl::OAuthClient.instance_variable_set(:@rcfile, nil) + + @options = Twurl::Options.test_app_only_exemplar + @client = Twurl::AppOnlyOAuthClient.test_app_only_exemplar + options.base_url = 'api.twitter.com' + options.request_method = 'get' + options.path = '/path/does/not/matter.json' + options.data = {} + options.headers = {} + + Twurl.options = options + end + + def teardown + super + Twurl.options = Twurl::Options.new + # Make sure we don't do any disk IO in these tests + assert !File.exist?(Twurl::RCFile.file_path) + end + + def test_nothing + # Appeasing test/unit + end +end + +class Twurl::AppOnlyOAuthClient::BasicMethods < Twurl::AppOnlyOAuthClient::AbstractClientTest + def test_needs_to_authorize? + client = Twurl::AppOnlyOAuthClient.new( + options.oauth_client_options.merge( + 'bearer_token' => nil + ) + ) + + fake_response = {:access_token => "test_bearer_token"} + mock(client).fetch_oauth2_token { fake_response } + + assert client.needs_to_authorize?, 'token should be nil' + client.exchange_credentials_for_access_token + assert !client.needs_to_authorize?, 'token should be exist' + end +end + +class Twurl::AppOnlyOAuthClient::ClientLoadingTest < Twurl::AppOnlyOAuthClient::AbstractClientTest + def test_attempting_to_load_a_bearer_token_from_non_authed_consumer_key_fails + mock(Twurl::OAuthClient.rcfile).save.times(any_times) + Twurl::OAuthClient.rcfile.bearer_token(options.consumer_key, options.bearer_token) + + assert Twurl::OAuthClient.rcfile.bearer_tokens.to_hash[options.consumer_key] + assert_nil Twurl::OAuthClient.rcfile.bearer_tokens.to_hash[:invalid_consumer_key] + + options.consumer_key = 'invalid_consumer_key' + assert_raises Twurl::Exception do + Twurl::OAuthClient.load_default_client(options) + end + end +end + +class Twurl::AppOnlyOAuthClient::PerformingRequestsFromAppOnlyClient < Twurl::AppOnlyOAuthClient::AbstractClientTest + def test_request_is_made_using_request_method_and_path_and_data_in_options + http = client.send(:http_client) + mock(client).http_client { http } + mock(http).request( + satisfy { |req| req.is_a?(Net::HTTP::Get) && (req.path == options.path) } + ) + client.perform_request_from_options(options) + end + + def test_user_agent_request_header_is_set + expected_ua_string = "twurl version: #{Twurl::Version} platform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" + + http = client.send(:http_client) + mock(client).http_client { http } + mock(http).request( + satisfy { |req| + req.is_a?(Net::HTTP::Get) && + req['user-agent'] == expected_ua_string + } + ) + client.perform_request_from_options(options) + end + + def test_request_options_are_setable + http = client.send(:http_client) + assert_equal 60, http.read_timeout + + options.timeout = 10 + http = client.send(:http_client) + + assert_equal 10, http.read_timeout + end +end diff --git a/test/authorization_controller_test.rb b/test/authorization_controller_test.rb index 9d2cba7..7e111f1 100644 --- a/test/authorization_controller_test.rb +++ b/test/authorization_controller_test.rb @@ -17,6 +17,21 @@ def test_successful_authentication_saves_retrieved_access_token controller.dispatch end + module AppOnlyAuth + def test_successful_app_only_authentication_saves_retrieved_access_token + app_only_client = Twurl::AppOnlyOAuthClient.test_app_only_exemplar + app_only_controller = Twurl::AuthorizationController.new(app_only_client, options) + + mock(app_only_client).exchange_credentials_for_access_token.times(1) + mock(app_only_client).save.times(1) + mock(app_only_controller).raise(Twurl::Exception, Twurl::AuthorizationController::AUTHORIZATION_FAILED_MESSAGE).never + mock(Twurl::CLI).puts(Twurl::AuthorizationController::AUTHORIZATION_SUCCEEDED_MESSAGE).times(1) + + app_only_controller.dispatch + end + end + include AppOnlyAuth + module ErrorCases def test_failed_authorization_does_not_save_client mock(client).exchange_credentials_for_access_token { raise OAuth::Unauthorized } diff --git a/test/cli_options_test.rb b/test/cli_options_test.rb index 53c3aa8..5db43ec 100644 --- a/test/cli_options_test.rb +++ b/test/cli_options_test.rb @@ -7,17 +7,8 @@ def setup end def test_base_url_is_built_from_protocol_and_host - options.protocol = 'http' - options.host = 'api.twitter.com' + options = Twurl::CLI.parse_options(['-H', 'ads-api.twitter.com']) - assert_equal 'http://api.twitter.com', options.base_url - end - - def test_ssl_is_enabled_if_the_protocol_is_https - options.protocol = 'http' - assert !options.ssl? - - options.protocol = 'https' - assert options.ssl? + assert_equal 'https://ads-api.twitter.com', options.base_url end end diff --git a/test/cli_test.rb b/test/cli_test.rb index 19012b7..34830c6 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -26,9 +26,10 @@ def test_unsupported_command_specified_sets_default_command module PathParsingTests def test_missing_path_throws_no_path_found stub(Twurl::CLI).puts - assert_raises Twurl::CLI::NoPathFound do + e = assert_raises Twurl::Exception do Twurl::CLI.parse_options([]) end + assert_equal 'No path found', e.message end def test_uri_params_are_encoded @@ -124,17 +125,12 @@ def test_extracting_an_empty_key_value_pair include DataParsingTests module RawDataParsingTests - def test_extracting_a_single_key_value_pair - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value']) - assert_equal({'key' => 'value'}, options.data) - - options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=value']) - assert_equal({'key' => 'value'}, options.data) - end + def test_raw_data_option_should_not_use_parser + options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=foo%26bar']) + assert_equal('key=foo%26bar', options.data) - def test_with_special_to_url_characters_in_value - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=a+%26%26+b+%2B%2B+c']) - assert_equal({'key' => 'a && b ++ c'}, options.data) + options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=foo%26bar']) + assert_equal('key=foo%26bar', options.data) end def test_passing_data_and_no_explicit_request_method_defaults_request_method_to_post @@ -144,26 +140,19 @@ def test_passing_data_and_no_explicit_request_method_defaults_request_method_to_ def test_passing_data_and_an_explicit_request_method_uses_the_specified_method options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value', '-X', 'DELETE']) - assert_equal({'key' => 'value'}, options.data) assert_equal 'delete', options.request_method end - def test_multiple_pairs_when_option_is_specified_multiple_times_on_command_line_collects_all - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value', '-d', 'another=pair']) - assert_equal({'key' => 'value', 'another' => 'pair'}, options.data) - end - - def test_multiple_pairs_separated_by_ampersand_are_all_captured - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=value+%26+value&another=pair']) - assert_equal({'key' => 'value & value', 'another' => 'pair'}, options.data) + def test_error_when_option_is_specified_multiple_times + assert_raises Twurl::Exception do + Twurl::CLI.parse_options([TEST_PATH, '-r', 'key1=value1', '-r', 'key2=value2']) + end end - def test_extracting_an_empty_key_value_pair - options = Twurl::CLI.parse_options([TEST_PATH, '-r', 'key=']) - assert_equal({'key' => ''}, options.data) - - options = Twurl::CLI.parse_options([TEST_PATH, '--raw-data', 'key=']) - assert_equal({'key' => ''}, options.data) + def test_error_when_option_is_specified_with_data_option + assert_raises Twurl::Exception do + Twurl::CLI.parse_options([TEST_PATH, '-r', 'key1=value1', '-d', 'key2=value2']) + end end end include RawDataParsingTests @@ -184,21 +173,6 @@ def test_multiple_headers_when_option_is_specified_multiple_times_on_command_lin end include HeaderParsingTests - module SSLDisablingTests - def test_ssl_is_on_by_default - options = Twurl::CLI.parse_options([TEST_PATH]) - assert options.ssl? - end - - def test_passing_no_ssl_option_disables_ssl - ['-U', '--no-ssl'].each do |switch| - options = Twurl::CLI.parse_options([TEST_PATH, switch]) - assert !options.ssl? - end - end - end - include SSLDisablingTests - module HostOptionTests def test_not_specifying_host_sets_it_to_the_default options = Twurl::CLI.parse_options([TEST_PATH]) @@ -226,7 +200,7 @@ def test_protocol_is_stripped_from_host module ProxyOptionTests def test_not_specifying_proxy_sets_it_to_nil options = Twurl::CLI.parse_options([TEST_PATH]) - assert_equal nil, options.proxy + assert_nil options.proxy end def test_setting_proxy_updates_to_requested_value @@ -239,4 +213,32 @@ def test_setting_proxy_updates_to_requested_value end end include ProxyOptionTests + + module TimeoutOptionTests + def test_not_specifying_timeout_sets_it_to_nil + options = Twurl::CLI.parse_options([TEST_PATH]) + assert_nil options.timeout + assert_nil options.connection_timeout + end + + def test_setting_timeout_updates_to_requested_value + options = Twurl::CLI.parse_options([TEST_PATH, '--timeout', '10', '--connection-timeout', '5']) + assert_equal 10, options.timeout + assert_equal 5, options.connection_timeout + end + end + include TimeoutOptionTests + + module AppOnlyOptionTests + def test_not_specifying_app_only_sets_it_to_nil + options = Twurl::CLI.parse_options([TEST_PATH]) + assert_nil options.app_only + end + + def test_specifying_app_only_updates_to_requested_value + options = Twurl::CLI.parse_options([TEST_PATH, '--bearer']) + assert options.app_only + end + end + include AppOnlyOptionTests end diff --git a/test/oauth_client_test.rb b/test/oauth_client_test.rb index 94fd85b..84bf1ab 100644 --- a/test/oauth_client_test.rb +++ b/test/oauth_client_test.rb @@ -1,6 +1,8 @@ require File.dirname(__FILE__) + '/test_helper' class Twurl::OAuthClient::AbstractOAuthClientTest < Minitest::Test + TEST_PATH = '/1.1/url/does/not/matter.json' + attr_reader :client, :options def setup Twurl::OAuthClient.instance_variable_set(:@rcfile, nil) @@ -20,7 +22,7 @@ def teardown super Twurl.options = Twurl::Options.new # Make sure we don't do any disk IO in these tests - assert !File.exists?(Twurl::RCFile.file_path) + assert !File.exist?(Twurl::RCFile.file_path) end def test_nothing @@ -46,12 +48,10 @@ def test_forced_reloading end class Twurl::OAuthClient::ClientLoadingFromOptionsTest < Twurl::OAuthClient::AbstractOAuthClientTest - def test_if_username_is_supplied_and_no_profile_exists_for_username_then_new_client_is_created - mock(Twurl::OAuthClient).load_client_for_username(options.username).never - mock(Twurl::OAuthClient).load_new_client_from_options(options).times(1) - mock(Twurl::OAuthClient).load_default_client.never - - Twurl::OAuthClient.load_from_options(options) + def test_if_username_is_supplied_and_no_profile_exists_for_username_then_no_profile_error + assert_raises Twurl::Exception do + Twurl::OAuthClient.load_from_options(options) + end end def test_if_username_is_supplied_and_profile_exists_for_username_then_client_is_loaded @@ -60,7 +60,7 @@ def test_if_username_is_supplied_and_profile_exists_for_username_then_client_is_ mock(Twurl::OAuthClient).load_client_for_username_and_consumer_key(options.username, options.consumer_key).times(1) mock(Twurl::OAuthClient).load_new_client_from_options(options).never - mock(Twurl::OAuthClient).load_default_client.never + mock(Twurl::OAuthClient).load_default_client(options).never Twurl::OAuthClient.load_from_options(options) end @@ -70,7 +70,52 @@ def test_if_username_is_not_provided_then_the_default_client_is_loaded mock(Twurl::OAuthClient).load_client_for_username(options.username).never mock(Twurl::OAuthClient).load_new_client_from_options(options).never - mock(Twurl::OAuthClient).load_default_client.times(1) + mock(Twurl::OAuthClient).load_default_client(options).times(1) + + Twurl::OAuthClient.load_from_options(options) + end + + def test_if_all_oauth_options_are_supplied_then_client_is_loaded_from_options + options.username = nil + options.command = 'request' + options.access_token = 'test_access_token' + options.token_secret = 'test_token_secret' + + mock(Twurl::OAuthClient).load_new_client_from_oauth_options(options).times(1) + mock(Twurl::OAuthClient).load_default_client(options).never + + Twurl::OAuthClient.load_from_options(options) + end + + def test_if_authorize_app_only_client_is_loaded + options = Twurl::Options.test_app_only_exemplar + options.command = 'authorize' + + mock(Twurl::OAuthClient).load_default_client.never + mock(Twurl::OAuthClient).load_client_for_app_only_auth(options, options.consumer_key).times(1) + + Twurl::OAuthClient.load_from_options(options) + end + + def test_if_app_only_options_is_supplied_on_request_then_app_only_client_is_loaded + options = Twurl::Options.test_app_only_exemplar + options.command = 'request' + + mock(Twurl::OAuthClient.rcfile).save.times(any_times) + Twurl::OAuthClient.rcfile << client + Twurl::OAuthClient.rcfile.bearer_token(options.consumer_key, options.bearer_token) + mock.proxy(Twurl::OAuthClient).load_default_client(options).times(1) + mock(Twurl::OAuthClient).load_client_for_app_only_auth(options, options.consumer_key).times(1) + + options.consumer_key = nil + Twurl::OAuthClient.load_from_options(options) + end + + def test_if_app_only_and_consumer_key_options_are_supplied_on_request_then_app_only_client_is_loaded + options = Twurl::Options.test_app_only_exemplar + options.command = 'request' + + mock(Twurl::OAuthClient).load_client_for_non_profile_app_only_auth(options).times(1) Twurl::OAuthClient.load_from_options(options) end @@ -100,7 +145,7 @@ def test_loading_default_client_when_there_is_none_fails assert_nil Twurl::OAuthClient.rcfile.default_profile assert_raises Twurl::Exception do - Twurl::OAuthClient.load_default_client + Twurl::OAuthClient.load_default_client(options) end end @@ -110,7 +155,7 @@ def test_loading_default_client_from_file Twurl::OAuthClient.rcfile << client assert_equal [client.username, client.consumer_key], Twurl::OAuthClient.rcfile.default_profile - client_from_file = Twurl::OAuthClient.load_default_client + client_from_file = Twurl::OAuthClient.load_default_client(options) assert_equal client.to_hash, client_from_file.to_hash end @@ -123,15 +168,26 @@ def setup @new_client = Twurl::OAuthClient.load_new_client_from_options(options) end - def test_password_is_included - assert_equal options.password, new_client.password - end - def test_oauth_options_are_passed_through assert_equal client.to_hash, new_client.to_hash end end +class Twurl::OAuthClient::NewClientLoadingFromOauthOptionsTest < Twurl::OAuthClient::AbstractOAuthClientTest + attr_reader :new_client + def setup + super + options.access_token = 'test_access_token' + options.token_secret = 'test_token_secret' + @new_client = Twurl::OAuthClient.load_new_client_from_oauth_options(options) + end + + def test_attributes_are_updated + assert_equal options.access_token, new_client.token + assert_equal options.token_secret, new_client.secret + end +end + class Twurl::OAuthClient::PerformingRequestsFromOptionsTest < Twurl::OAuthClient::AbstractOAuthClientTest def test_request_is_made_using_request_method_and_path_and_data_in_options client = Twurl::OAuthClient.test_exemplar @@ -145,10 +201,7 @@ def test_request_is_made_using_request_method_and_path_and_data_in_options def test_content_type_is_not_overridden_if_set_and_data_in_options client = Twurl::OAuthClient.test_exemplar - - options.request_method = 'post' - options.data = { '{ "foo": "bar" }' => nil } - options.headers = { 'Content-Type' => 'application/json' } + options = Twurl::CLI.parse_options([TEST_PATH, '-d', '{ "foo": "bar" }', '-A', 'Content-Type: application/json']) mock(client.consumer.http).request( satisfy { |req| req.is_a?(Net::HTTP::Post) && req.content_type == 'application/json' } @@ -159,9 +212,7 @@ def test_content_type_is_not_overridden_if_set_and_data_in_options def test_content_type_is_set_to_form_encoded_if_not_set_and_data_in_options client = Twurl::OAuthClient.test_exemplar - - options.request_method = 'post' - options.data = { '{ "foo": "bar" }' => nil } + options = Twurl::CLI.parse_options([TEST_PATH, '-d', 'foo=bar']) mock(client.consumer.http).request( satisfy { |req| req.is_a?(Net::HTTP::Post) && req.content_type == 'application/x-www-form-urlencoded' } @@ -169,6 +220,35 @@ def test_content_type_is_set_to_form_encoded_if_not_set_and_data_in_options client.perform_request_from_options(options) end + + def test_post_body_is_parsed_and_escaped_properly_through_request_builder + client = Twurl::OAuthClient.test_exemplar + options = Twurl::CLI.parse_options([TEST_PATH, '-d', 'foo=text%26text']) + + mock(client.consumer.http).request( + satisfy { |req| + req.is_a?(Net::HTTP::Post) && + req.content_type == 'application/x-www-form-urlencoded' && + req.body == 'foo=text%26text' + } + ) + + client.perform_request_from_options(options) + end + + def test_user_agent_request_header_is_set + client = Twurl::OAuthClient.test_exemplar + expected_ua_string = "twurl version: #{Twurl::Version} platform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})" + + mock(client.consumer.http).request( + satisfy { |req| + req.is_a?(Net::HTTP::Get) && + req['user-agent'] == expected_ua_string + } + ) + + client.perform_request_from_options(options) + end end class Twurl::OAuthClient::CredentialsForAccessTokenExchangeTest < Twurl::OAuthClient::AbstractOAuthClientTest @@ -176,15 +256,15 @@ def test_successful_exchange_parses_token_and_secret_from_response_body parsed_response = {:oauth_token => "123456789", :oauth_token_secret => "abcdefghi", :user_id => "3191321", - :screen_name => "noradio", - :x_auth_expires => "0"} + :screen_name => "noradio" + } mock(client.consumer). token_request(:post, client.consumer.access_token_path, nil, {}, - client.client_auth_parameters) { parsed_response } + ) { parsed_response } assert client.needs_to_authorize? client.exchange_credentials_for_access_token diff --git a/test/rcfile_test.rb b/test/rcfile_test.rb index cf5cc31..22ff3b3 100644 --- a/test/rcfile_test.rb +++ b/test/rcfile_test.rb @@ -108,6 +108,27 @@ def test_adding_additional_clients_does_not_change_default_profile end end +class Twurl::RCFile::BearerTokenTest < Minitest::Test + attr_reader :rcfile + def setup + @rcfile = Twurl::RCFile.new + mock(rcfile).save.times(any_times) + end + + def test_save_bearer_token + options = Twurl::Options.test_app_only_exemplar + expected_response = { + options.consumer_key => options.bearer_token + } + rcfile << Twurl::OAuthClient.test_exemplar + rcfile.bearer_token(options.consumer_key, options.bearer_token) + + client = Twurl::AppOnlyOAuthClient.test_app_only_exemplar + + assert_equal expected_response, rcfile.bearer_tokens + end +end + class Twurl::RCFile::SavingTest < Minitest::Test attr_reader :rcfile def setup @@ -136,7 +157,7 @@ def test_file_is_not_world_readable private def rcfile_exists? - File.exists?(Twurl::RCFile.file_path) + File.exist?(Twurl::RCFile.file_path) end def delete_rcfile diff --git a/test/request_controller_test.rb b/test/request_controller_test.rb index 92427e0..9720575 100644 --- a/test/request_controller_test.rb +++ b/test/request_controller_test.rb @@ -24,6 +24,7 @@ def test_request_will_be_made_if_client_is_authorized mock(client).needs_to_authorize? { false }.times(1) mock(controller).perform_request.times(1) + options.path = '/test/path' controller.dispatch end @@ -41,8 +42,26 @@ class Twurl::RequestController::RequestTest < Twurl::RequestController::Abstract def test_request_response_is_written_to_output expected_body = 'this is a fake response body' response = Object.new - mock(response).read_body { |block| block.call expected_body } - mock(client).perform_request_from_options(options).times(1) { |options, block| block.call(response) } + mock(response).read_body { |&block| block.call expected_body } + mock(client).perform_request_from_options(options).times(1) { |_options, &block| block.call(response) } + + controller.perform_request + + assert_equal expected_body, Twurl::CLI.output.string + end + + def test_request_response_is_json_formatted + response_body = '{"data": {"text": "this is a fake response"}}' + expected_body = "{\n" \ + " \"data\": {\n" \ + " \"text\": \"this is a fake response\"\n" \ + " }\n" \ + "}" + custom_options = options + custom_options.json_format = true + response = Object.new + mock(response).read_body { |&block| block.call response_body } + mock(client).perform_request_from_options(custom_options).times(1) { |_custom_options, &block| block.call(response) } controller.perform_request @@ -50,9 +69,12 @@ def test_request_response_is_written_to_output end def test_invalid_or_unspecified_urls_report_error - mock(Twurl::CLI).puts(Twurl::RequestController::NO_URI_MESSAGE).times(1) mock(client).perform_request_from_options(options).times(1) { raise URI::InvalidURIError } - controller.perform_request + e = assert_raises Twurl::Exception do + controller.perform_request + end + + assert_equal Twurl::RequestController::INVALID_URI_MESSAGE, e.message end end diff --git a/test/test_helper.rb b/test/test_helper.rb index e22e451..0230b68 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,6 @@ require 'simplecov' -require 'coveralls' -SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter] +SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter] SimpleCov.start @@ -9,7 +8,7 @@ require 'minitest/autorun' require 'rr' -Twurl::RCFile.directory = ENV['TMPDIR'] +Twurl::RCFile.directory = ENV['TMPDIR'] || File.dirname(__FILE__) module Twurl class Options @@ -17,12 +16,21 @@ class << self def test_exemplar options = new options.username = 'exemplar_user_name' - options.password = 'secret' options.consumer_key = '123456789' options.consumer_secret = '987654321' options.subcommands = [] options end + + def test_app_only_exemplar + options = new + options.app_only = true + options.consumer_key = '123456789' + options.consumer_secret = '987654321' + options.bearer_token = 'test_bearer_token' + options.subcommands = [] + options + end end end @@ -39,4 +47,17 @@ def test_exemplar(overrides = {}) end end end + + class AppOnlyOAuthClient + class << self + def test_app_only_exemplar + options = Twurl::Options.test_app_only_exemplar + Twurl::AppOnlyOAuthClient.new( + options.oauth_client_options.merge( + 'bearer_token' => options.bearer_token + ) + ) + end + end + end end diff --git a/twurl.gemspec b/twurl.gemspec index 77b7de8..688a5f4 100644 --- a/twurl.gemspec +++ b/twurl.gemspec @@ -1,24 +1,22 @@ -# coding: utf-8 lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'twurl/version' Gem::Specification.new do |spec| spec.add_dependency 'oauth', '~> 0.4' - spec.add_development_dependency 'bundler', '~> 1.0' - spec.authors = ["Marcel Molina", "Erik Michaels-Ober"] + spec.add_dependency 'ostruct', '>= 0.3.3' + spec.authors = ["Marcel Molina", "Erik Michaels-Ober", "@TwitterDev team"] spec.description = %q{Curl for the Twitter API} - spec.email = ['marcel@twitter.com'] - spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } - spec.extra_rdoc_files = %w(COPYING INSTALL README) - spec.files = `git ls-files -z`.split("\x0").reject { |f| f.start_with?('test/') } - spec.homepage = 'http://github.com/twitter/twurl' + spec.bindir = 'bin' + spec.executables << 'twurl' + spec.extra_rdoc_files = Dir["*.md", "LICENSE"] + spec.files = Dir["*.md", "LICENSE", "twurl.gemspec", "bin/*", "lib/**/*"] + spec.homepage = 'https://github.com/twitter/twurl' spec.licenses = ['MIT'] spec.name = 'twurl' - spec.rdoc_options = ['--title', 'twurl -- OAuth-enabled curl for the Twitter API', '--main', 'README', '--line-numbers', '--inline-source'] + spec.rdoc_options = ['--title', 'twurl -- OAuth-enabled curl for the Twitter API', '--main', 'README.md', '--line-numbers', '--inline-source'] spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 1.9.3' - spec.rubyforge_project = 'twurl' + spec.required_ruby_version = '>= 2.5.0' spec.summary = spec.description spec.version = Twurl::Version end