├── Dockerfile.fpm ├── .travis.yml ├── src ├── methods.cr ├── version.cr ├── formatters │ ├── plain.cr │ ├── auto.cr │ ├── json.cr │ └── xml.cr ├── completion.cr ├── formatters.cr ├── crul.cr ├── cookie_store.cr ├── command.cr └── options.cr ├── .gitignore ├── crul.cr ├── shard.lock ├── Dockerfile.alpine ├── shard.yml ├── Makefile ├── spec ├── formatters │ ├── plain_spec.cr │ ├── json_spec.cr │ ├── xml_spec.cr │ └── auto_spec.cr ├── integration │ ├── auth_spec.cr │ ├── headers_spec.cr │ ├── data_spec.cr │ ├── cookies_spec.cr │ └── basic_spec.cr ├── spec_helper.cr ├── cookie_store_spec.cr └── options_spec.cr ├── release.linux ├── RELEASE.md ├── CHANGELOG.md ├── LICENSE.txt ├── PACKAGING.md └── README.md /Dockerfile.fpm: -------------------------------------------------------------------------------- 1 | FROM ruby 2 | 3 | RUN gem install fpm 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | crystal: 3 | - latest 4 | -------------------------------------------------------------------------------- /src/methods.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | enum Methods 3 | GET 4 | POST 5 | PUT 6 | DELETE 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .crystal 2 | crul 3 | .github 4 | release 5 | .deps 6 | *.zip 7 | libs 8 | lib 9 | .shards 10 | build/ 11 | -------------------------------------------------------------------------------- /crul.cr: -------------------------------------------------------------------------------- 1 | require "./src/*" 2 | 3 | # Crul::Completion.setup 4 | 5 | if Crul::CLI.run!(ARGV, STDOUT) 6 | exit 7 | else 8 | exit -1 9 | end 10 | -------------------------------------------------------------------------------- /src/version.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | VERSION = "0.4.2" 3 | 4 | def self.version_string 5 | "crul #{VERSION} (#{{{`date -u`.strip.stringify}}})" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/formatters/plain.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | module Formatters 3 | class Plain < Base 4 | def print 5 | @output.puts @response.body 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | completion: 4 | github: porras/completion 5 | commit: a1d7d1f62e7b274a376a6f180c0f49fb0dade295 6 | 7 | webmock: 8 | github: manastech/webmock.cr 9 | commit: ca7c79ab3cc8b4c56cb1d70ba7a6952dc35ef8f6 10 | 11 | -------------------------------------------------------------------------------- /Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:0.34.0-alpine 2 | 3 | ADD crul.cr /src/ 4 | ADD src /src/src 5 | ADD shard.* /src/ 6 | ADD Makefile /src/ 7 | 8 | WORKDIR /src 9 | 10 | RUN make 11 | 12 | # delete binary if present (it's a darwin one probably) 13 | RUN rm -f crul 14 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crul 2 | version: 0.4.2 3 | 4 | dependencies: 5 | completion: 6 | github: porras/completion 7 | branch: crystal-0.19 8 | 9 | development_dependencies: 10 | webmock: 11 | github: manastech/webmock.cr 12 | branch: master 13 | 14 | license: MIT 15 | authors: 16 | - Sergio Gil 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: crul 2 | 3 | crul: crul.cr src/**/*.cr 4 | shards 5 | crystal build --release --no-debug --static --link-flags "-lxml2 -llzma" crul.cr 6 | @strip crul 7 | @du -sh crul 8 | 9 | clean: 10 | rm -rf .crystal crul .deps .shards libs 11 | 12 | PREFIX ?= /usr/local 13 | 14 | install: crul 15 | install -d $(PREFIX)/bin 16 | install crul $(PREFIX)/bin 17 | -------------------------------------------------------------------------------- /spec/formatters/plain_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Crul::Formatters::Plain do 4 | describe "#print" do 5 | it "prints" do 6 | output = IO::Memory.new 7 | response = FakeResponse.new("Hello") 8 | formatter = Crul::Formatters::Plain.new(output, response) 9 | 10 | formatter.print 11 | 12 | output.to_s.strip.should eq("Hello") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/completion.cr: -------------------------------------------------------------------------------- 1 | require "completion" 2 | 3 | module Crul 4 | module Completion 5 | macro setup 6 | completion :options do |comp| 7 | comp.on :options do 8 | comp.reply [ 9 | "get", "post", "put", "delete", 10 | "--help", 11 | "--data", "--header", "--auth", "--cookies", 12 | "--json", "--xml", "--plain", 13 | ] 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/integration/auth_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Basic auth" do 4 | it "sends the basic auth data" do 5 | WebMock.stub(:get, "http://example.org/auth") 6 | .with(headers: {"Authorization" => "Basic #{Base64.strict_encode("user:secret")}"}) 7 | .to_return(body: "Hello, World") 8 | 9 | lines = capture_lines do |output| 10 | Crul::CLI.run!(["get", "http://example.org/auth", "-a", "user:secret"], output).should be_true 11 | end 12 | 13 | lines.first.should eq("HTTP/1.1 200 OK") 14 | lines.last.should eq("Hello, World") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/integration/headers_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Sending headers" do 4 | it "sends the headers" do 5 | WebMock.stub(:get, "http://example.org/headers") 6 | .with(headers: {"Hello" => "World", "Header" => "Value"}) 7 | .to_return(body: "Hello, World") 8 | 9 | lines = capture_lines do |output| 10 | Crul::CLI.run!(["get", "http://example.org/headers", "-H", "Hello:World", "-H", "Header:Value"], output).should be_true 11 | end 12 | 13 | lines.first.should eq("HTTP/1.1 200 OK") 14 | lines.last.should eq("Hello, World") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/formatters.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | enum Format 3 | Auto 4 | XML 5 | JSON 6 | Plain 7 | end 8 | 9 | module Formatters 10 | MAP = { 11 | Format::Auto => Formatters::Auto, 12 | Format::XML => Formatters::XML, 13 | Format::JSON => Formatters::JSON, 14 | Format::Plain => Formatters::Plain, 15 | } 16 | 17 | def self.new(format, *args) 18 | MAP[format].new(*args) 19 | end 20 | 21 | abstract class Base 22 | def initialize(@output : IO, @response : HTTP::Client::Response) 23 | end 24 | 25 | def print_plain 26 | Plain.new(@output, @response).print 27 | end 28 | end 29 | end 30 | end 31 | 32 | require "./formatters/*" 33 | -------------------------------------------------------------------------------- /src/crul.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | module CLI 3 | def self.run!(argv, output) 4 | options = Options.parse(argv) 5 | 6 | if options.help? 7 | output.puts Options::USAGE 8 | return true 9 | end 10 | 11 | if options.version? 12 | output.puts Crul.version_string 13 | return true 14 | end 15 | 16 | if options.errors.any? 17 | output.puts Options::USAGE 18 | 19 | output.puts "Errors:" 20 | options.errors.each do |error| 21 | output.puts " * " + error.to_s 22 | end 23 | output.puts 24 | return false 25 | end 26 | 27 | Command.new(output, options).run! 28 | true 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/formatters/auto.cr: -------------------------------------------------------------------------------- 1 | module Crul 2 | module Formatters 3 | class Auto 4 | @formatter : Base 5 | 6 | getter :formatter 7 | 8 | def initialize(output, response) 9 | content_type = response.headers.fetch("Content-type", "text/plain").split(';').first 10 | formatter_class = case content_type 11 | when "application/json", "application/vnd.api+json" 12 | JSON 13 | when "application/xml" 14 | XML 15 | else 16 | Plain 17 | end 18 | @formatter = formatter_class.new(output, response) 19 | end 20 | 21 | def print(*args) 22 | @formatter.print(*args) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/formatters/json_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Crul::Formatters::JSON do 4 | describe "#print" do 5 | context "with valid JSON" do 6 | it "formats it" do 7 | output = IO::Memory.new 8 | response = FakeResponse.new("{\"a\":1}") 9 | formatter = Crul::Formatters::JSON.new(output, response) 10 | 11 | formatter.print 12 | 13 | Hash(String, Int32).from_json(uncolorize(output.to_s)).should eq({"a" => 1}) 14 | end 15 | end 16 | 17 | context "with invalid JSON" do 18 | it "formats it (falling back to plain)" do 19 | output = IO::Memory.new 20 | response = FakeResponse.new("{{{") 21 | formatter = Crul::Formatters::JSON.new(output, response) 22 | 23 | formatter.print 24 | 25 | output.to_s.strip.should eq("{{{") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /release.linux: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | alias docker=podman 6 | 7 | rm -rf build 8 | 9 | docker build -t crul.alpine -f Dockerfile.alpine . 10 | docker build -t crul.fpm -f Dockerfile.fpm . 11 | 12 | mkdir -p build 13 | 14 | docker run -v $PWD/build:/build -w /src -e "PREFIX=/build/usr" crul.alpine make install 15 | 16 | docker run -v $PWD/build:/build -w /build crul.fpm \ 17 | fpm \ 18 | --input-type dir \ 19 | --output-type deb \ 20 | --name crul \ 21 | --version 0.4.1 \ 22 | --chdir /build \ 23 | --package crul_VERSION_ARCH.deb \ 24 | --license MIT \ 25 | --category web \ 26 | --maintainer "Sergio Gil " \ 27 | --url https://github.com/porras/crul \ 28 | --description "Crul is a curl replacement, that is, it's a command line HTTP client. It has fewer features and options, but it aims to be more user friendly. It's heavily inspired by httpie." \ 29 | usr/bin 30 | -------------------------------------------------------------------------------- /spec/integration/data_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Sending data" do 4 | it "sends the data" do 5 | WebMock.stub(:post, "http://example.org/data") 6 | .with(body: "Hello") 7 | .to_return(body: "World") 8 | 9 | lines = capture_lines do |output| 10 | Crul::CLI.run!(["post", "http://example.org/data", "-d", "Hello"], output).should be_true 11 | end 12 | 13 | lines.first.should eq("HTTP/1.1 200 OK") 14 | lines.last.should eq("World") 15 | end 16 | 17 | it "sends the data from a file" do 18 | WebMock.stub(:post, "http://example.org/data") 19 | .with(body: File.read(__FILE__)) 20 | .to_return(body: "World") 21 | 22 | lines = capture_lines do |output| 23 | Crul::CLI.run!(["post", "http://example.org/data", "-d", "@#{__FILE__}"], output).should be_true 24 | end 25 | 26 | lines.first.should eq("HTTP/1.1 200 OK") 27 | lines.last.should eq("World") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/formatters/xml_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Crul::Formatters::XML do 4 | describe "#print" do 5 | context "with valid XML" do 6 | it "formats it" do 7 | output = IO::Memory.new 8 | response = FakeResponse.new("c") 9 | formatter = Crul::Formatters::XML.new(output, response) 10 | 11 | formatter.print 12 | 13 | # we should parse and assert on output but it'll be much easier with the new XML features in next Crystal release 14 | # for the time being no exception raised is Good Enough™ 15 | end 16 | end 17 | 18 | context "with malformed XML" do 19 | it "formats it (falling back to plain)" do 20 | output = IO::Memory.new 21 | response = FakeResponse.new("<<<") 22 | formatter = Crul::Formatters::XML.new(output, response) 23 | 24 | formatter.print 25 | 26 | output.to_s.strip.should eq("<<<") 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/integration/cookies_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Cookies" do 4 | it "stores and sends the cookies" do 5 | WebMock.stub(:get, "example.org/cookies/set") 6 | .to_return(headers: {"Set-Cookie" => "k1=v1; Path=/"}, body: "Cookie set") 7 | 8 | WebMock.stub(:get, "example.org/cookies/check") 9 | .with(headers: {"Cookie" => "k1=v1; Path=/"}) 10 | .to_return(body: "Cookie received") 11 | 12 | lines = capture_lines do |output| 13 | Crul::CLI.run!(["get", "http://example.org/cookies/set", "-c", "/tmp/cookies"], output).should be_true 14 | end 15 | 16 | lines.first.should eq("HTTP/1.1 200 OK") 17 | lines.last.should eq("Cookie set") 18 | 19 | lines = capture_lines do |output| 20 | Crul::CLI.run!(["get", "http://example.org/cookies/check", "-c", "/tmp/cookies"], output).should be_true 21 | end 22 | 23 | lines.first.should eq("HTTP/1.1 200 OK") 24 | lines.last.should eq("Cookie received") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to release 2 | 3 | 1. Make sure [master build](https://travis-ci.org/porras/crul) is green 4 | 1. Make a commit on master with the message *vX.Y.Z* and the following changes 5 | * In `CHANGELOG.md`, add a section with the new version as header and the content of the *Unreleased* one. Don't forget to add the date and fix the link. Add a new *Unreleased* header, with the proper link. 6 | * In `src/crul/version.cr`, update the version 7 | * In `shard.yml`, update the version 8 | 1. Run `shards` 9 | 1. Run the specs locally 10 | 1. Add the tag: `git tag vX.Y.Z` 11 | 1. Push: `git push origin master --tags` 12 | 1. Make sure [master build](https://travis-ci.org/porras/crul) is green again 13 | 1. [Draft a new release](https://github.com/porras/crul/releases/new) 14 | * Tag: The version being released 15 | * Release title: vX.Y.Z 16 | * Description: Should include the relevant part of the `CHANGELOG` and a link to installation instructions (see [example](https://github.com/porras/crul/releases/tag/0.4.1)) 17 | 1. Publish it! 18 | 19 | See `PACKAGING.md` on how to release source and binary packages. 20 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/*" 3 | require "webmock" 4 | 5 | struct FakeResponse 6 | getter :body, :headers 7 | 8 | def initialize(@body = "", content_type = nil) 9 | @headers = HTTP::Headers.new 10 | if content_type 11 | @headers["Content-Type"] = content_type 12 | end 13 | end 14 | end 15 | 16 | abstract class Crul::Formatters::Base 17 | def initialize(@output : IO, @response : FakeResponse) 18 | end 19 | end 20 | 21 | def capture_lines(uncolorize? = true, &block) 22 | output = IO::Memory.new 23 | yield(output) 24 | string = output.to_s 25 | string = uncolorize(string) if uncolorize? 26 | string.strip.split("\n") 27 | end 28 | 29 | Spec.before_each do 30 | WebMock.reset 31 | end 32 | 33 | def uncolorize(string) 34 | String.build do |output| 35 | ignore = false 36 | string.chars.each do |char| 37 | if ignore 38 | if char == 'm' 39 | ignore = false 40 | end 41 | else 42 | if char == '\e' 43 | ignore = true 44 | else 45 | output << char 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [Unreleased](https://github.com/porras/crul/compare/v0.4.2...HEAD) 6 | 7 | ## [0.4.2](https://github.com/porras/crul/compare/v0.4.1...v0.4.2) (2017-08-31) 8 | ### Added 9 | - Autodetect JSON API format([#24](https://github.com/porras/crul/pull/24), thanks [nilsding](https://github.com/nilsding)) 10 | 11 | ### Fixed 12 | - Bug on colorizing output ([#22](https://github.com/porras/crul/pull/22), thanks [straight-shoota](https://github.com/straight-shoota)) 13 | 14 | ### Other 15 | - Crystal 0.23 compatibility ([#21](https://github.com/porras/crul/pull/21) 16 | 17 | ## [0.4.1](https://github.com/porras/crul/compare/v0.4.0...v0.4.1) (2016-11-17) 18 | ### Fixed 19 | - Bug on formatting floats ([#18](https://github.com/porras/crul/issues/18)) 20 | 21 | ### Added 22 | - This CHANGELOG and RELEASE process 23 | 24 | ## [0.4.0](https://github.com/porras/crul/tree/v0.4.0) - 2015-12-30 (previous release) 25 | ## [0.1.0](https://github.com/porras/crul/tree/v0.1.0) - 2015-03-16 (initial release) 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Sergio Gil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cookie_store.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module Crul 4 | class CookieStore 5 | @filename : String? 6 | 7 | getter :filename 8 | 9 | alias Cookies = Hash(String, Hash(String, String)) 10 | 11 | def load(filename) 12 | @filename = filename 13 | 14 | return unless File.exists?(filename) 15 | 16 | @cookies = Cookies.from_json(File.read(filename)) 17 | end 18 | 19 | def add_to_headers(host, port, headers) 20 | if cookies_for_host = cookies["#{host}:#{port}"]? 21 | cookies_for_host.each do |name, cookie| 22 | headers["Cookie"] = cookie 23 | end 24 | end 25 | end 26 | 27 | def store_cookies(host, port, headers) 28 | if cookie_header = headers["Set-Cookie"]? 29 | cookies["#{host}:#{port}"] ||= {} of String => String 30 | cookies["#{host}:#{port}"][cookie_name(cookie_header)] = cookie_header 31 | end 32 | end 33 | 34 | def write! 35 | if filename = @filename 36 | json = cookies.to_json 37 | Dir.mkdir_p(File.dirname(filename)) 38 | File.write(filename, json) 39 | end 40 | end 41 | 42 | private def cookie_name(cookie_header) 43 | cookie_header.split('=', 2).first 44 | end 45 | 46 | private def cookies 47 | @cookies ||= Cookies.new 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/cookie_store_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Crul::CookieStore do 4 | it "stores cookies" do 5 | cookie_store = Crul::CookieStore.new 6 | cookie_store.store_cookies("example.com", 80, {"Set-Cookie" => "wadus=5"}) 7 | 8 | headers = HTTP::Headers.new 9 | 10 | cookie_store.add_to_headers("example.com", 443, headers) # different port 11 | 12 | headers.empty?.should be_true 13 | 14 | cookie_store.add_to_headers("example.es", 80, headers) # different host 15 | 16 | headers.empty?.should be_true 17 | 18 | cookie_store.add_to_headers("example.com", 80, headers) # alles gut 19 | 20 | headers["Cookie"].should eq("wadus=5") 21 | end 22 | 23 | it "writes/reads cookies to/from file" do 24 | cookie_store = Crul::CookieStore.new 25 | cookie_store.load("/tmp/cookies.json") 26 | cookie_store.store_cookies("example.com", 80, {"Set-Cookie" => "wadus=5"}) 27 | 28 | cookie_store.write! 29 | 30 | Hash(String, Hash(String, String)).from_json(File.read("/tmp/cookies.json")).should eq({"example.com:80" => {"wadus" => "wadus=5"}}) 31 | 32 | cookie_store = Crul::CookieStore.new 33 | cookie_store.load("/tmp/cookies.json") 34 | 35 | headers = HTTP::Headers.new 36 | 37 | cookie_store.add_to_headers("example.com", 80, headers) 38 | 39 | headers["Cookie"].should eq("wadus=5") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /PACKAGING.md: -------------------------------------------------------------------------------- 1 | # How to update Homebrew recipe 2 | 3 | 1. [Install current version with Homebrew](https://github.com/porras/crul#mac) if it wasn't installed 4 | 1. `brew edit crul` 5 | * Edit `url` 6 | * Remove `bottle` section 7 | 1. `brew uninstall crul` 8 | 1. `brew install crul` 9 | * This will fail because the checksum doesn't match. The new one will be shown 10 | * **Check it** 11 | * Update it running `brew edit crul` again 12 | * Retry the install 13 | 1. Optionally, create a *bottle* (see below) 14 | 1. `cd /usr/local/Homebrew/Library/Taps/porras/homebrew-tap` 15 | 1. Commit (message can be `[crul] vX.Y.Z`) and push 16 | * If login is prompted, cancel and add a `mine` remote with `git remote add mine git@github.com:porras/homebrew-tap.git`) 17 | * From now on the push command will be `git push mine master` 18 | 19 | ## How to create a *bottle* 20 | 21 | 1. Uninstall and reinstall with `brew install crul --build-bottle` 22 | 1. `brew bottle crul` 23 | 1. Upload the generated `tar.gz` to the release and copy its download URL 24 | 1. Copy the generated snippet into `brew edit crul` and add a `root_url` line with the copied URL **minus the filename** 25 | 1. Check it by uninstalling and installing again (no flags) 26 | 27 | # How to build and release Ubuntu package 28 | 29 | 1. Run `./release.linux` (requires Docker, and probably a fast internet connection 😁) 30 | 1. Add the generated `build/whatever.deb` to the deb repository 31 | -------------------------------------------------------------------------------- /spec/formatters/auto_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Crul::Formatters::Auto do 4 | describe "#formatter" do 5 | it "detects JSON" do 6 | output = IO::Memory.new 7 | response = FakeResponse.new(content_type: "application/json") 8 | formatter = Crul::Formatters::Auto.new(output, response) 9 | 10 | formatter.formatter.should be_a(Crul::Formatters::JSON) 11 | end 12 | 13 | it "detects JSONAPI" do 14 | output = IO::Memory.new 15 | response = FakeResponse.new(content_type: "application/vnd.api+json") 16 | formatter = Crul::Formatters::Auto.new(output, response) 17 | 18 | formatter.formatter.should be_a(Crul::Formatters::JSON) 19 | end 20 | 21 | it "detects XML" do 22 | output = IO::Memory.new 23 | response = FakeResponse.new(content_type: "application/xml") 24 | formatter = Crul::Formatters::Auto.new(output, response) 25 | 26 | formatter.formatter.should be_a(Crul::Formatters::XML) 27 | end 28 | 29 | it "defaults to plain" do 30 | output = IO::Memory.new 31 | response = FakeResponse.new(content_type: "text/csv") 32 | formatter = Crul::Formatters::Auto.new(output, response) 33 | 34 | formatter.formatter.should be_a(Crul::Formatters::Plain) 35 | end 36 | 37 | it "works without a header" do 38 | output = IO::Memory.new 39 | response = FakeResponse.new 40 | formatter = Crul::Formatters::Auto.new(output, response) 41 | 42 | formatter.formatter.should be_a(Crul::Formatters::Plain) 43 | end 44 | 45 | it "works with an encoding" do 46 | output = IO::Memory.new 47 | response = FakeResponse.new(content_type: "application/xml; charset=ISO-8859-1") 48 | formatter = Crul::Formatters::Auto.new(output, response) 49 | 50 | formatter.formatter.should be_a(Crul::Formatters::XML) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /src/command.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "colorize" 3 | 4 | module Crul 5 | class Command 6 | @host : String 7 | @port : Int32 8 | 9 | def initialize(@output : IO, @options : Options) 10 | @host = @options.url.host.not_nil! 11 | @port = @options.url.port || default_port 12 | end 13 | 14 | def run! 15 | connect do |client| 16 | @options.cookie_store.add_to_headers(@host, @port, @options.headers) 17 | 18 | response = client.exec(@options.method.to_s, @options.url.full_path, @options.headers, @options.body) 19 | 20 | print_response response 21 | 22 | @options.cookie_store.store_cookies(@host, @port, response.headers) 23 | @options.cookie_store.write! 24 | end 25 | end 26 | 27 | private def connect 28 | HTTP::Client.new(@host, @port, @options.url.scheme == "https") do |client| 29 | if basic_auth = @options.basic_auth 30 | client.basic_auth(*basic_auth) 31 | end 32 | 33 | begin 34 | yield client 35 | ensure 36 | client.close 37 | end 38 | end 39 | rescue e : IO::TimeoutError | Socket::Error 40 | puts e.message 41 | exit -1 42 | end 43 | 44 | private def print_response(response) 45 | Colorize.with.light_blue.surround(@output) { |io| io << response.version } 46 | Colorize.with.cyan.surround(@output) { |io| io << " #{response.status_code} " } 47 | Colorize.with.yellow.surround(@output) { |io| io.puts response.status_message } 48 | response.headers.each do |name, values| 49 | values.each do |value| 50 | @output << "#{name}: " 51 | Colorize.with.cyan.surround(@output) { |io| io.puts value } 52 | end 53 | end 54 | @output.puts 55 | Formatters.new(@options.format, @output, response).print 56 | end 57 | 58 | private def default_port 59 | @options.url.scheme == "https" ? 443 : 80 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/formatters/json.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "json/pull_parser" 3 | require "colorize" 4 | 5 | module Crul 6 | module Formatters 7 | class JSON < Base 8 | def print 9 | begin 10 | printer = PrettyPrinter.new(@response.body, @output) 11 | printer.print 12 | @output.puts 13 | rescue ::JSON::ParseException 14 | print_plain 15 | end 16 | end 17 | 18 | # taken almost verbatim from https://github.com/crystal-lang/crystal/blob/257eaa23b40bb4abef2f82029697c2785b9cb588/samples/pretty_json.cr 19 | # needed changes: 20 | # * @input is IO | String instead of IO 21 | # * JSON constant needs to be “rooted” (::JSON) 22 | class PrettyPrinter 23 | def initialize(@input : IO | String, @output : IO) 24 | @pull = ::JSON::PullParser.new @input 25 | @indent = 0 26 | end 27 | 28 | def print 29 | read_any 30 | end 31 | 32 | def read_any 33 | case @pull.kind 34 | when .null? 35 | Colorize.with.bold.surround(@output) do 36 | @pull.read_null.to_json(@output) 37 | end 38 | when .bool? 39 | Colorize.with.light_blue.surround(@output) do 40 | @pull.read_bool.to_json(@output) 41 | end 42 | when .int? 43 | Colorize.with.red.surround(@output) do 44 | @pull.read_int.to_json(@output) 45 | end 46 | when .float? 47 | Colorize.with.red.surround(@output) do 48 | @pull.read_float.to_json(@output) 49 | end 50 | when .string? 51 | Colorize.with.yellow.surround(@output) do 52 | @pull.read_string.to_json(@output) 53 | end 54 | when .begin_array? 55 | read_array 56 | when .begin_object? 57 | read_object 58 | when .eof? 59 | # We are done 60 | else 61 | raise "Bug: unexpected kind: #{@pull.kind}" 62 | end 63 | end 64 | 65 | def read_array 66 | print "[\n" 67 | @indent += 1 68 | i = 0 69 | @pull.read_array do 70 | if i > 0 71 | print ',' 72 | print '\n' if @indent > 0 73 | end 74 | print_indent 75 | read_any 76 | i += 1 77 | end 78 | @indent -= 1 79 | print '\n' 80 | print_indent 81 | print ']' 82 | end 83 | 84 | def read_object 85 | print "{\n" 86 | @indent += 1 87 | i = 0 88 | @pull.read_object do |key| 89 | if i > 0 90 | print ',' 91 | print '\n' if @indent > 0 92 | end 93 | print_indent 94 | Colorize.with.cyan.surround(@output) do 95 | key.to_json(@output) 96 | end 97 | print ": " 98 | read_any 99 | i += 1 100 | end 101 | @indent -= 1 102 | print '\n' 103 | print_indent 104 | print '}' 105 | end 106 | 107 | def print_indent 108 | @indent.times { @output << " " } 109 | end 110 | 111 | def print(value) 112 | @output << value 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/integration/basic_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Basic examples" do 4 | it "no args" do 5 | lines = capture_lines do |output| 6 | Crul::CLI.run!([] of String, output).should be_false 7 | end 8 | 9 | lines.first.should match(/\AUsage:/) 10 | lines.last.should match(/Please specify URL/) 11 | end 12 | 13 | it "help" do 14 | lines = capture_lines do |output| 15 | Crul::CLI.run!(["-h"], output).should be_true 16 | end 17 | 18 | lines.first.should match(/\AUsage:/) 19 | end 20 | 21 | it "most basic GET" do 22 | WebMock.stub(:get, "http://example.org/").to_return(body: "Hello", headers: {"Hello" => "World"}) 23 | 24 | lines = capture_lines do |output| 25 | Crul::CLI.run!(["http://example.org"], output).should be_true 26 | end 27 | 28 | lines.first.should eq("HTTP/1.1 200 OK") 29 | lines.should contain("Hello: World") 30 | lines.last.should eq("Hello") 31 | end 32 | 33 | it "colorizes output" do 34 | WebMock.stub(:get, "http://example.org/").to_return(body: "Hello", headers: {"Hello" => "World"}) 35 | 36 | lines = capture_lines(uncolorize?: false) do |output| 37 | Crul::CLI.run!(["http://example.org"], output).should be_true 38 | end 39 | 40 | lines.first.should eq("\e[94mHTTP/1.1\e[0m\e[36m 200 \e[0m\e[33mOK") 41 | lines.should contain("\e[0mHello: \e[36mWorld") 42 | lines.last.should eq("Hello") 43 | end 44 | 45 | it "most basic GET with https" do 46 | WebMock.stub(:get, "https://example.org/").to_return(body: "Hello") 47 | 48 | lines = capture_lines do |output| 49 | Crul::CLI.run!(["https://example.org"], output).should be_true 50 | end 51 | 52 | lines.first.should eq("HTTP/1.1 200 OK") 53 | lines.last.should eq("Hello") 54 | end 55 | 56 | it "most basic GET without protocol (should default to http://)" do 57 | WebMock.stub(:get, "http://example.org/").to_return(body: "Hello") 58 | 59 | lines = capture_lines do |output| 60 | Crul::CLI.run!(["example.org"], output).should be_true 61 | end 62 | 63 | lines.first.should eq("HTTP/1.1 200 OK") 64 | lines.last.should eq("Hello") 65 | end 66 | 67 | it "most basic GET with port" do 68 | WebMock.stub(:get, "http://example.org:8080/").to_return(body: "Hello") 69 | 70 | lines = capture_lines do |output| 71 | Crul::CLI.run!(["http://example.org:8080/"], output).should be_true 72 | end 73 | 74 | lines.first.should eq("HTTP/1.1 200 OK") 75 | lines.last.should eq("Hello") 76 | end 77 | 78 | it "basic POST" do 79 | WebMock.stub(:post, "http://example.org/").to_return(body: "Hello") 80 | 81 | lines = capture_lines do |output| 82 | Crul::CLI.run!(["post", "http://example.org"], output).should be_true 83 | end 84 | 85 | lines.first.should eq("HTTP/1.1 200 OK") 86 | lines.last.should eq("Hello") 87 | end 88 | 89 | it "basic PUT" do 90 | WebMock.stub(:put, "http://example.org/").to_return(body: "Hello") 91 | 92 | lines = capture_lines do |output| 93 | Crul::CLI.run!(["put", "http://example.org"], output).should be_true 94 | end 95 | 96 | lines.first.should eq("HTTP/1.1 200 OK") 97 | lines.last.should eq("Hello") 98 | end 99 | 100 | it "basic DELETE" do 101 | WebMock.stub(:delete, "http://example.org/").to_return(body: "Hello") 102 | 103 | lines = capture_lines do |output| 104 | Crul::CLI.run!(["delete", "http://example.org"], output).should be_true 105 | end 106 | 107 | lines.first.should eq("HTTP/1.1 200 OK") 108 | lines.last.should eq("Hello") 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /src/formatters/xml.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | require "colorize" 3 | 4 | module Crul 5 | module Formatters 6 | class XML < Base 7 | def print 8 | printer = PrettyPrinter.new(@response.body, @output) 9 | if printer.valid_xml? 10 | printer.print 11 | @output.puts 12 | else 13 | print_plain 14 | end 15 | end 16 | 17 | class PrettyPrinter 18 | def initialize(@input : IO | String, @output : IO) 19 | @reader = ::XML::Reader.new(@input) 20 | @indent = 0 21 | end 22 | 23 | # This is a stupid way of doing this, but at some point between Crystal 0.8.0 and 0.10.0 XML::Reader stopped 24 | # raising exceptions or reporting errors in the way XML.parse does. Real solution would be either 1) fix that in 25 | # Crystal, or 2) make the Printer use XML.parse (not inefficient because it's not doing streaming anyway). But 26 | # for the time being, this is the smallest change that fixes it 27 | def valid_xml? 28 | xml = ::XML.parse(@input) 29 | !xml.errors 30 | end 31 | 32 | def print 33 | current = Element.new 34 | 35 | while @reader.read 36 | case @reader.node_type 37 | when ::XML::Reader::Type::ELEMENT 38 | elem = Element.new(@reader.name, current) 39 | empty = @reader.empty_element? 40 | current = elem unless empty 41 | 42 | print_start_open_element elem.name 43 | 44 | if @reader.has_attributes? 45 | if @reader.move_to_first_attribute 46 | print_attribute @reader.name, @reader.value 47 | while @reader.move_to_next_attribute 48 | print_attribute @reader.name, @reader.value 49 | end 50 | end 51 | end 52 | 53 | print_end_open_element empty 54 | when ::XML::Reader::Type::END_ELEMENT 55 | parent = current.parent 56 | if parent 57 | print_close_element current.name 58 | current = parent 59 | else 60 | raise "Invalid end element" 61 | end 62 | when ::XML::Reader::Type::TEXT 63 | print_text @reader.value 64 | when ::XML::Reader::Type::COMMENT 65 | print_comment @reader.value 66 | end 67 | end 68 | end 69 | 70 | private def print_start_open_element(name) 71 | Colorize.with.cyan.surround(@output) do 72 | @output << "#{" " * @indent}<#{name}" 73 | end 74 | end 75 | 76 | private def print_end_open_element(empty) 77 | Colorize.with.cyan.surround(@output) do 78 | if empty 79 | @output << "/>\n" 80 | else 81 | @indent += 1 82 | @output << ">\n" 83 | end 84 | end 85 | end 86 | 87 | private def print_close_element(name) 88 | @indent -= 1 89 | Colorize.with.cyan.surround(@output) do 90 | @output << "#{" " * @indent}\n" 91 | end 92 | end 93 | 94 | private def print_attribute(name, value) 95 | Colorize.with.cyan.surround(@output) do 96 | @output << " #{name}=" 97 | end 98 | Colorize.with.yellow.surround(@output) do 99 | @output << "\"#{value}\"" 100 | end 101 | end 102 | 103 | private def print_text(text) 104 | Colorize.with.yellow.surround(@output) do 105 | @output << "#{" " * @indent}#{text.strip}\n" 106 | end 107 | end 108 | 109 | private def print_comment(comment) 110 | Colorize.with.light_blue.surround(@output) do 111 | @output << "#{" " * @indent}\n" 112 | end 113 | end 114 | 115 | class Element 116 | getter :name, :parent 117 | 118 | def initialize(@name : String? = nil, @parent : Element? = nil) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/options_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Crul::Options do 4 | describe ".parse" do 5 | it "GET with JSON" do 6 | options = Crul::Options.parse("GET http://example.org -j".split(" ")) 7 | 8 | options.method.should eq(Crul::Methods::GET) 9 | options.url.should be_a(URI) 10 | options.url.to_s.should eq("http://example.org") 11 | options.format.should eq(Crul::Format::JSON) 12 | options.basic_auth.should eq(nil) 13 | options.cookie_store.filename.should eq(nil) 14 | end 15 | 16 | it "POST with JSON" do 17 | options = Crul::Options.parse("POST http://example.org -j".split(" ")) 18 | 19 | options.method.should eq(Crul::Methods::POST) 20 | options.url.should be_a(URI) 21 | options.url.to_s.should eq("http://example.org") 22 | options.format.should eq(Crul::Format::JSON) 23 | end 24 | 25 | it "defaults to auto formatter" do 26 | options = Crul::Options.parse("GET http://example.org".split(" ")) 27 | 28 | options.method.should eq(Crul::Methods::GET) 29 | options.url.should be_a(URI) 30 | options.url.to_s.should eq("http://example.org") 31 | options.format.should eq(Crul::Format::Auto) 32 | end 33 | 34 | it "defaults to GET" do 35 | options = Crul::Options.parse("-j http://example.org".split(" ")) 36 | 37 | options.method.should eq(Crul::Methods::GET) 38 | options.url.should be_a(URI) 39 | options.url.to_s.should eq("http://example.org") 40 | options.format.should eq(Crul::Format::JSON) 41 | end 42 | 43 | it "GET with XML" do 44 | options = Crul::Options.parse("GET http://example.org -x".split(" ")) 45 | 46 | options.method.should eq(Crul::Methods::GET) 47 | options.url.should be_a(URI) 48 | options.url.to_s.should eq("http://example.org") 49 | options.format.should eq(Crul::Format::XML) 50 | end 51 | 52 | it "GET with plain" do 53 | options = Crul::Options.parse("GET http://example.org -p".split(" ")) 54 | 55 | options.method.should eq(Crul::Methods::GET) 56 | options.url.should be_a(URI) 57 | options.url.to_s.should eq("http://example.org") 58 | options.format.should eq(Crul::Format::Plain) 59 | end 60 | 61 | it "most basic" do 62 | options = Crul::Options.parse("http://example.org".split(" ")) 63 | 64 | options.method.should eq(Crul::Methods::GET) 65 | options.url.should be_a(URI) 66 | options.url.to_s.should eq("http://example.org") 67 | options.format.should eq(Crul::Format::Auto) 68 | end 69 | 70 | it "without protocol" do 71 | options = Crul::Options.parse("example.org".split(" ")) 72 | 73 | options.url.should be_a(URI) 74 | options.url.to_s.should eq("http://example.org") 75 | end 76 | 77 | it "accepts a request body" do 78 | options = Crul::Options.parse("http://example.org -d data".split(" ")) 79 | 80 | options.body.should eq("data") 81 | end 82 | 83 | it "accepts a request body as a file" do 84 | options = Crul::Options.parse("http://example.org -d @LICENSE.txt".split(" ")) 85 | 86 | options.errors.empty?.should be_true 87 | options.body.should match(/\AThe MIT License/) 88 | end 89 | 90 | it "manages a file not found" do 91 | options = Crul::Options.parse("http://example.org -d @wadus.txt".split(" ")) 92 | 93 | options.errors.empty?.should_not be_true 94 | options.body.should be_nil 95 | end 96 | 97 | it "accepts headers" do 98 | options = Crul::Options.parse("http://example.org -H header1:value1 -H header2:value2".split(" ")) 99 | 100 | options.headers["Header1"].should eq("value1") 101 | options.headers["Header2"].should eq("value2") 102 | end 103 | 104 | it "accepts headers with JSON values" do 105 | header_value = {"a" => "b"} 106 | options = Crul::Options.parse("http://example.org -H JSON:#{header_value.to_json}".split(" ")) 107 | 108 | Hash(String, String).from_json(options.headers["json"]).should eq(header_value) 109 | end 110 | 111 | it "gets user and password with --auth" do 112 | options = Crul::Options.parse("GET http://example.org --auth foo:bar".split(" ")) 113 | options.basic_auth.should eq({"foo", "bar"}) 114 | end 115 | 116 | it "gets user and password with -a" do 117 | options = Crul::Options.parse("GET http://example.org -a foo:bar".split(" ")) 118 | options.basic_auth.should eq({"foo", "bar"}) 119 | end 120 | 121 | it "reads and writes cookies from file" do 122 | options = Crul::Options.parse("GET http://example.org -c /tmp/cookies.json".split(" ")) 123 | options.cookie_store.should be_a(Crul::CookieStore) 124 | options.cookie_store.filename.should eq("/tmp/cookies.json") 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /src/options.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | 3 | module Crul 4 | class Options 5 | property :format, :method, :body, :headers, :basic_auth, :cookie_store, :errors 6 | property! :url, :parser 7 | property? :help, :version 8 | 9 | @body : String? 10 | @basic_auth : Tuple(String, String)? 11 | @help : Bool? 12 | @version : Bool? 13 | @url : URI? 14 | 15 | @parser : OptionParser? 16 | 17 | def initialize 18 | @format = Format::Auto 19 | @method = Methods::GET 20 | @headers = HTTP::Headers.new 21 | @cookie_store = CookieStore.new 22 | @errors = [] of Exception 23 | end 24 | 25 | USAGE = <<-USAGE 26 | Usage: crul [method] URL [options] 27 | 28 | HTTP methods (default: GET): 29 | get, GET Use GET 30 | post, POST Use POST 31 | put, PUT Use PUT 32 | delete, DELETE Use DELETE 33 | 34 | HTTP options: 35 | -d DATA, --data DATA Request body 36 | -d @file, --data @file Request body (read from file) 37 | -H HEADER, --header HEADER Set header 38 | -a USER:PASS, --auth USER:PASS Basic auth 39 | -c FILE, --cookies FILE Use FILE as cookie store (reads and writes) 40 | 41 | Response formats (default: autodetect): 42 | -j, --json Format response as JSON 43 | -x, --xml Format response as XML 44 | -p, --plain Format response as plain text 45 | 46 | Other options: 47 | -h, --help Show this help 48 | -V, --version Display version 49 | USAGE 50 | 51 | def self.parse(args) 52 | new.tap do |options| 53 | case args.first? 54 | when "get", "GET" 55 | args.shift 56 | options.method = Methods::GET 57 | when "post", "POST" 58 | args.shift 59 | options.method = Methods::POST 60 | when "put", "PUT" 61 | args.shift 62 | options.method = Methods::PUT 63 | when "delete", "DELETE" 64 | args.shift 65 | options.method = Methods::DELETE 66 | else # it's the default 67 | options.method = Methods::GET 68 | end 69 | 70 | options.parser = OptionParser.parse(args) do |parser| 71 | parser.separator "HTTP options:" 72 | parser.on("-d DATA", "--data DATA", "Request body") do |body| 73 | options.body = if body.starts_with?('@') 74 | begin 75 | File.read(body[1..-1]) 76 | rescue e 77 | options.errors << e 78 | nil 79 | end 80 | else 81 | body 82 | end 83 | end 84 | parser.on("-d @file", "--data @file", "Request body (read from file)") { } # previous handler 85 | parser.on("-H HEADER", "--header HEADER", "Set header") do |header| 86 | name, value = header.split(':', 2) 87 | options.headers[name] = value 88 | end 89 | parser.on("-a USER:PASS", "--auth USER:PASS", "Basic auth") do |user_pass| 90 | pieces = user_pass.split(':', 2) 91 | options.basic_auth = {pieces[0], pieces[1]? || ""} 92 | end 93 | parser.on("-c FILE", "--cookies FILE", "Use FILE as cookie store (reads and writes)") do |file| 94 | options.cookie_store.load(file) 95 | end 96 | 97 | parser.separator 98 | parser.separator "Response formats (default: autodetect):" 99 | parser.on("-j", "--json", "Format response as JSON") do |method| 100 | options.format = Format::JSON 101 | end 102 | parser.on("-x", "--xml", "Format response as XML") do |method| 103 | options.format = Format::XML 104 | end 105 | parser.on("-p", "--plain", "Format response as plain text") do |method| 106 | options.format = Format::Plain 107 | end 108 | 109 | parser.separator 110 | parser.separator "Other options:" 111 | parser.on("-h", "--help", "Show this help") do 112 | options.help = true 113 | end 114 | parser.on("-V", "--version", "Display version") do 115 | options.version = true 116 | end 117 | 118 | parser.unknown_args do |args| 119 | if args.empty? 120 | options.errors << Exception.new("Please specify URL") 121 | else 122 | options.url = parse_uri(args.first) 123 | end 124 | end 125 | 126 | parser.separator 127 | end 128 | end 129 | end 130 | 131 | private def self.parse_uri(string) 132 | uri = URI.parse(string) 133 | uri = URI.parse("http://#{string}") if uri.host.nil? 134 | uri 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crul [![Build Status](https://travis-ci.org/porras/crul.svg?branch=master)](https://travis-ci.org/porras/crul) 2 | 3 | Crul is a [curl](http://curl.haxx.se/) replacement, that is, it's a command line 4 | HTTP client. It has fewer features and options, but it aims to be more user 5 | friendly. It's heavily inspired by 6 | [httpie](https://github.com/jakubroztocil/httpie). 7 | 8 | It's written in the [Crystal](http://crystal-lang.org/) language. It's in an 9 | early stage but it allows already basic usage. 10 | 11 | ## Features 12 | 13 | * Fast 14 | * No dependencies, easy to install 15 | * Basic HTTP features (method, request body, headers) 16 | * Syntax highlighting of the output (JSON and XML) 17 | * Basic authentication 18 | * Cookie store 19 | * Bash completion of commands and options 20 | 21 | ## Planned features 22 | 23 | * User friendly headers and request body generation (similar to 24 | [httpie's](https://github.com/jakubroztocil/httpie#request-items)) 25 | * Digest authentication 26 | * More fancy stuff 27 | 28 | ## Installation 29 | 30 | ### Mac 31 | 32 | brew tap porras/crul 33 | brew install crul 34 | 35 | Or, if you want to install the latest, unreleased version: 36 | 37 | brew tap porras/tap 38 | brew install crul --HEAD 39 | 40 | ### Linux 41 | 42 | #### Ubuntu/Debian 43 | 44 | There is an APT repository with signed packages of the latest crul version. To setup this repo and install crul, run the following commands (as root or with sudo): 45 | 46 | apt-key adv --keyserver keys.gnupg.net --recv-keys ED2715FE 47 | echo "deb http://iamserg.io/deb packages main" > /etc/apt/sources.list.d/iamserg.io.list 48 | apt-get update 49 | apt-get install crul 50 | 51 | #### Other distributions 52 | 53 | See how to [install from source](#from-source) below. 54 | 55 | ### From source 56 | 57 | If there are no binary packages for your OS version, you can install `crul` [downloading the zip or tarball](https://github.com/porras/crul/releases/latest) and building it from source. See 58 | [Development](#development) for instructions. 59 | 60 | ## Completion 61 | 62 | After installation, add this line to your `.bashrc` (only Bash supported at this moment): 63 | 64 | eval "$(crul --completion)" 65 | 66 | You don't need this if you installed via Homebrew (it's automatic). 67 | 68 | ## Usage 69 | 70 | Usage: crul [method] URL [options] 71 | 72 | HTTP methods (default: GET): 73 | get, GET Use GET 74 | post, POST Use POST 75 | put, PUT Use PUT 76 | delete, DELETE Use DELETE 77 | 78 | HTTP options: 79 | -d DATA, --data DATA Request body 80 | -d @file, --data @file Request body (read from file) 81 | -H HEADER, --header HEADER Set header 82 | -a USER:PASS, --auth USER:PASS Basic auth 83 | -c FILE, --cookies FILE Use FILE as cookie store (reads and writes) 84 | 85 | Response formats (default: autodetect): 86 | -j, --json Format response as JSON 87 | -x, --xml Format response as XML 88 | -p, --plain Format response as plain text 89 | 90 | Other options: 91 | -h, --help Show this help 92 | -V, --version Display version 93 | 94 | ## Examples 95 | 96 | ### GET request 97 | 98 | $ crul http://httpbin.org/get?a=b 99 | HTTP/1.1 200 OK 100 | Server: nginx 101 | Date: Wed, 11 Mar 2015 07:57:33 GMT 102 | Content-type: application/json 103 | Content-length: 179 104 | Connection: keep-alive 105 | Access-control-allow-origin: * 106 | Access-control-allow-credentials: true 107 | 108 | { 109 | "args": { 110 | "a": "b" 111 | }, 112 | "headers": { 113 | "Content-Length": "0", 114 | "Host": "httpbin.org" 115 | }, 116 | "origin": "188.103.25.204", 117 | "url": "http://httpbin.org/get?a=b" 118 | } 119 | 120 | ### PUT request 121 | 122 | $ crul put http://httpbin.org/put -d '{"a":"b"}' -H Content-Type:application/json 123 | HTTP/1.1 200 OK 124 | Server: nginx 125 | Date: Wed, 11 Mar 2015 07:58:54 GMT 126 | Content-type: application/json 127 | Content-length: 290 128 | Connection: keep-alive 129 | Access-control-allow-origin: * 130 | Access-control-allow-credentials: true 131 | 132 | { 133 | "args": {}, 134 | "data": "{\"a\":\"b\"}", 135 | "files": {}, 136 | "form": {}, 137 | "headers": { 138 | "Content-Length": "9", 139 | "Content-Type": "application/json", 140 | "Host": "httpbin.org" 141 | }, 142 | "json": { 143 | "a": "b" 144 | }, 145 | "origin": "188.103.25.204", 146 | "url": "http://httpbin.org/put" 147 | } 148 | 149 | ## Development 150 | 151 | You'll need [Crystal 0.33](https://crystal-lang.org/install/) installed (it might work with older 152 | or newer versions, but that's the one that's tested). 153 | 154 | After checking out the repo (or decompressing the tarball with the source code), run `shards` to get 155 | the development dependencies, and `make` to run the tests and compile the source. Optionally, you 156 | can run `make install` to install it (as a default, in /usr/local/bin, override it running 157 | `PREFIX=/opt/whatever make install`). 158 | 159 | ## Contributing 160 | 161 | 1. Fork it ( https://github.com/porras/crul/fork ) 162 | 2. Create your feature branch (`git checkout -b my-new-feature`) 163 | 3. Commit your changes (`git commit -am 'Add some feature'`) 164 | 4. Push to the branch (`git push origin my-new-feature`) 165 | 5. Create a new Pull Request 166 | 167 | You can also contribute by trying it and reporting any 168 | [issue](https://github.com/porras/crul/issues) you find. 169 | 170 | ## Copyright 171 | 172 | Copyright (c) 2015-2016 Sergio Gil. See 173 | [LICENSE](https://github.com/porras/crul/blob/master/LICENSE.txt) for details. 174 | --------------------------------------------------------------------------------