Full Code of icyleaf/halite for AI

master 07dc2f01f69b cached
56 files
242.5 KB
65.9k tokens
1 requests
Download .txt
Showing preview only (259K chars total). Download the full file or copy to clipboard to get everything.
Repository: icyleaf/halite
Branch: master
Commit: 07dc2f01f69b
Files: 56
Total size: 242.5 KB

Directory structure:
gitextract_d07hks70/

├── .editorconfig
├── .github/
│   ├── scripts/
│   │   └── generate_docs.sh
│   └── workflows/
│       ├── api-document.yml
│       ├── linux-ci.yml
│       └── release-version.yml
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── shard.yml
├── spec/
│   ├── fixtures/
│   │   └── cache_file.json
│   ├── halite/
│   │   ├── client_spec.cr
│   │   ├── error_spec.cr
│   │   ├── ext/
│   │   │   ├── http_headers_encode_spec.cr
│   │   │   └── http_params_encode_spec.cr
│   │   ├── feature_spec.cr
│   │   ├── features/
│   │   │   ├── cache_spec.cr
│   │   │   └── logging_spec.cr
│   │   ├── header_link_spec.cr
│   │   ├── mime_type_spec.cr
│   │   ├── mime_types/
│   │   │   └── json_spec.cr
│   │   ├── options/
│   │   │   ├── follow_spec.cr
│   │   │   └── timeout_spec.cr
│   │   ├── options_spec.cr
│   │   ├── rate_limit_spec.cr
│   │   ├── redirector_spec.cr
│   │   ├── request_spec.cr
│   │   └── response_spec.cr
│   ├── halite_spec.cr
│   ├── spec_helper.cr
│   └── support/
│       ├── mock_server/
│       │   └── route_handler.cr
│       └── mock_server.cr
└── src/
    ├── halite/
    │   ├── chainable.cr
    │   ├── client.cr
    │   ├── error.cr
    │   ├── ext/
    │   │   ├── file_to_json.cr
    │   │   ├── http_headers_encode.cr
    │   │   └── http_params_encode.cr
    │   ├── feature.cr
    │   ├── features/
    │   │   ├── cache.cr
    │   │   ├── logging/
    │   │   │   ├── common.cr
    │   │   │   └── json.cr
    │   │   └── logging.cr
    │   ├── form_data.cr
    │   ├── header_link.cr
    │   ├── mime_type.cr
    │   ├── mime_types/
    │   │   └── json.cr
    │   ├── options/
    │   │   ├── follow.cr
    │   │   └── timeout.cr
    │   ├── options.cr
    │   ├── rate_limit.cr
    │   ├── redirector.cr
    │   ├── request.cr
    │   └── response.cr
    └── halite.cr

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true


================================================
FILE: .github/scripts/generate_docs.sh
================================================
#!/usr/bin/env sh

DOCS_PATH="docs"
TAGS=$(git tag -l)
DEFAULT_VERSION=$(git tag -l | sort -V | tail -n 1)
DEFAULT_VERSION=$(echo $DEFAULT_VERSION | awk '{gsub(/^v/, ""); print}')

if [ -z "$DEFAULT_VERSION" ]; then
  echo "Not fount default version"
  exit 1
fi

# Clean up
rm -rf $DOCS_PATH
mkdir -p $DOCS_PATH

# Generate master docs
COMMIT_DATE=$(git log -1 --format=%ci)
MASTER_COMMIT_HASH=$(git rev-parse --short HEAD)
COMMIT_STATUS="[#${MASTER_COMMIT_HASH}](${GH_REF}/commit/${MASTER_COMMIT_HASH})"
sed -i -e "s/latest commit/$(echo ${COMMIT_STATUS} | sed -e "s/\//\\\\\//g") (${COMMIT_DATE})/" README.md
crystal docs --output="${DOCS_PATH}/master" --project-version="master-dev" --json-config-url="/halite/version.json"
git reset --hard

version_gt () {
  test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"
}

echo "{\"versions\": [" > docs/version.json
echo "{\"name\": \"master-dev\", \"url\": \"/halite/master/\", \"released\": false}" >> docs/version.json

# Generate version docs
for TAG in $(git tag -l | sort -r -V); do
  NAME=$(echo $TAG | awk '{gsub(/^v/, ""); print}')

  # Crystal version 1.0 complie version must great than 0.12.0.
  if version_gt $NAME "0.11.0"; then
    git checkout -b $NAME $TAG

    echo ",{\"name\": \"$NAME\", \"url\": \"/halite/$NAME/\"}" >> docs/version.json

    COMMIT_STATUS="[${TAG}](${GH_REF}/blob/master/CHANGELOG.md)"
    sed -i -e "s/latest commit/$(echo ${COMMIT_STATUS} | sed -e "s/\//\\\\\//g")/" README.md
    crystal docs --output="${DOCS_PATH}/${NAME}" --project-version="${NAME}" --json-config-url="/halite/version.json"
    git reset --hard
    git checkout master
    git branch -d $NAME
  fi
done

echo "]}" >> docs/version.json

echo "<html>
<header>
  <meta http-equiv='Refresh' content='0; url='${GH_URL}/${DEFAULT_VERSION}/' />
  <script language='javascript' type='text/javascript'>
    window.location.href='${GH_URL}/${DEFAULT_VERSION}/';
  </script>
</header>
<body>
<p><a href='${GH_URL}/${DEFAULT_VERSION}/'>Redirect to ${DEFAULT_VERSION}</a></p>
</body>
</html>" > "${DOCS_PATH}/index.html"


================================================
FILE: .github/workflows/api-document.yml
================================================
name: Deploy API documents
on:
  push:
    paths-ignore:
      - "benchmarks/**"
    branches:
      - "master"
    tags:
      - "v*"

env:
  DOCS_PATH: docs
  GH_REF: https://github.com/icyleaf/halite
  GH_URL: https://icyleaf.github.io/halite

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
      with:
        # fetch all tags and branches
        fetch-depth: 0
    - uses: oprypin/install-crystal@v1
    - name: Generate
      id: generate
      run: |
        chmod +x .github/scripts/generate_docs.sh
        ./.github/scripts/generate_docs.sh
    - name: Deploy
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: ${{ env.DOCS_PATH }}




================================================
FILE: .github/workflows/linux-ci.yml
================================================
name: Linux CI
on:
  push:
    paths-ignore:
      - "benchmarks/**"
    branches:
      - "master"
  pull_request:
    branches: "*"

jobs:
  specs:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        crystal: [ '1.0.0', 'latest', 'nightly' ]
    name: Crystal ${{ matrix.crystal }} tests
    steps:
    - uses: actions/checkout@master
    - uses: oprypin/install-crystal@v1
      with:
        crystal: ${{ matrix.crystal }}
    - name: Install dependencies
      run: shards install
    - name: Run tests
      run: crystal spec --error-on-warnings --error-trace
    - name: Run code format check
      run: |
        if ! crystal tool format --check; then
          crystal tool format
          git diff
          exit 1
        fi


================================================
FILE: .github/workflows/release-version.yml
================================================
name: Deploy new release
on:
  push:
    tags:
      - "v*"

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - name: Create Release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: ${{ github.ref }}
        release_name: Release ${{ github.ref }}
        draft: false
        prerelease: false



================================================
FILE: .gitignore
================================================
docs/
lib/
bin/
logs/
.shards/

# Local test file
main.cr

# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
shard.lock
.history/
cache/


================================================
FILE: CHANGELOG.md
================================================
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

> List all changes before release a new version.

### Todo

- [ ] Rewrite HTTP Connection
  - [ ] New Connection for Halite
  - [x] Proxy support
  - [ ] Reuse connection

## [0.12.1] (2021-11-04)

### Fixed

- Reduce alloc too many memory. [#108](https://github.com/icyleaf/halite/pull/108) (thanks @[wolfgang371](https://github.com/wolfgang371))

## [0.12.0] (2021-03-24)

### Fixed

- Compatibility with Crystal 1.0.

## [0.11.0] (2021-02-18)

> Finally, the major version was out! Happy new year!

### Changed

- **[breaking changing]** Drop file logging in favor of Crystal's [Log](https://crystal-lang.org/api/0.36.1/Log.html). (removed `.logging(file: )`, use `.logging(for: )` instead)  [#101](https://github.com/icyleaf/halite/pull/101) (thanks @[oprypin](https://github.com/oprypin))
- Pre-read `TZ` environment value to convert timestamp's timezone during logging output. [#102](https://github.com/icyleaf/halite/pull/102)
- Crystal 0.34.x support.

## [0.10.9] (2021-02-01)

### Fixed

- `timeout` fail to match argument type. [#97](https://github.com/icyleaf/halite/issues/97) (thanks @[oprypin](https://github.com/oprypin))
- Compatibility with Crystal 0.36.0.

## [0.10.8] (2020-12-22)

### Fixed

- Resolve path of endpoint ending without slash. [#94](https://github.com/icyleaf/halite/issues/94) (thanks @[mipmip](https://github.com/mipmip))

## [0.10.7] (2020-12-08)
### Fixed

- Fix initial status_message. [#91](https://github.com/icyleaf/halite/issues/91) (thanks @[oprypin](https://github.com/oprypin))

## [0.10.6] (2020-11-24)
### Fixed

- Improve resolve of URI. [#88](https://github.com/icyleaf/halite/issues/88) (thanks @[oprypin](https://github.com/oprypin))

## [0.10.5] (2020-04-15)

### Fixed

- Compatibility with Crystal 0.34.0.

## [0.10.4] (2019-09-26)

### Fixed

- Compatibility with Crystal 0.31.0.

## [0.10.3] (2019-08-12)

### Fixed

- Compatibility with Crystal 0.30.0.

## [0.10.2] (2019-06-24)

### Fixed

- Fixed Basic Auth creates bad headers in crystal 0.29.0. [#73](https://github.com/icyleaf/halite/pull/73) (thanks @[kalinon](https://github.com/kalinon))
- Fixed use one shared options in multiple instanced `Halite::Client`. [#72](https://github.com/icyleaf/halite/issues/72) (thanks @[qszhu](https://github.com/qszhu))

## [0.10.1] (2019-05-28)

### Fixed

- Fixed duplica query and backslash when reuse client. [#67](https://github.com/icyleaf/halite/pull/67), [#68](https://github.com/icyleaf/halite/issues/68) (thanks @[watzon](https://github.com/watzon))
- Fixed no effect to call `logging(true)` method in Crystal 0.28. [#69](https://github.com/icyleaf/halite/issues/69)

## [0.10.0] (2019-05-20)

### Added

- Add `endpoint` chainable method, also add it as configuration option to reuse client. [#66](https://github.com/icyleaf/halite/pull/66)

## [0.9.2] (2019-05-20)

### Fixed

- Compatibility with Crystal 0.28.0

### Changed

- Drop Crystal 0.25.x, 0.26.x, 0.27.x support.

## [0.9.1] (2019-01-14)

> Minor typo fix (same as v0.9.0)

### Fixed

- Correct version both in `shard.yml` and `version.cr`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey))
- Update basic auth example in `README.md`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey))

## [0.9.0] (2018-12-21)

> New features with performance improved.

### Added

- Add streaming requests (feature to store binary data chunk by chunk) [#53](https://github.com/icyleaf/halite/pull/53)
- Add `user_agent` to Chainable methods. [#55](https://github.com/icyleaf/halite/pull/55)

### Fixed

- Fix overwrite the value with default headers when use `merge` or  `merge!` method in `Halite::Options`. [#54](https://github.com/icyleaf/halite/pull/54)

### Changed

- Remove default headers in `Halite::Options`.
- Move header `User-Agent` to `Halite::Request`.
- Change header `Connection` from "keep-alive" to "close" to `Halite::Request`.
- Remove header `Accept`.

## [0.8.0] (2018-11-30)

> Compatibility with Crystal 0.27 and serious bugfix.

### Changed

- **[breaking changing]** Rename `logger` to `logging`, `with_logger` to `with_logging`. [#52](https://github.com/icyleaf/halite/pull/52)
- **[breaking changing]** Remove `logging` argument in `Halite::Options.new` and `Halite::Client.new`. [#51](https://github.com/icyleaf/halite/pull/51)
- **[breaking changing]** Remove `logging?` method in `Halite::Options`, use `logging` method instead. [#51](https://github.com/icyleaf/halite/pull/51)
- Change `logging` behavior check if features is exists any class of superclasses is `Halite::Logging` instead of given a Bool type.
- Rename prefix `X-Cache` to `X-Halite-Cache` in cache feature.

### Added

- Allow `timeout` method passed single `read` or `connect` method.
- Add `merge!` and `dup` methods in `Halite::Options`. [#51](https://github.com/icyleaf/halite/pull/51)

### Fixed

- Fix duplice add "Content-Type" into header during request. [#50](https://github.com/icyleaf/halite/pull/50)
- Fix non overwrite value of headers use `Halite::Options.merge` method. [#50](https://github.com/icyleaf/halite/pull/50)
- Fix always overwrite and return merged option in a instanced class(session mode), see updated note in [Session](https://github.com/icyleaf/halite#sessions).

### Tested

- Compatibility with Crystal 0.27
- Add specs with Crystal 0.25, 0.26 and 0.27 in Circle CI.

## [0.7.5] (2018-10-31)

### Changed

- **[breaking changing]** Rename argument name `ssl` to `tls` in `Halite::Client`/`Halite::Options`/`Halite::Chainable`.

### Fixed

- Fix new a `Halite::Client` instance with empty block return `Nil`. [#44](https://github.com/icyleaf/halite/issues/44)

## [0.7.4] (2018-10-30)

### Fixed

- Fix typos in document and comments. [#43](https://github.com/icyleaf/halite/issues/43) (thanks @[GloverDonovan](https://github.com/GloverDonovan))

## [0.7.3] (2018-10-18)

### Fixed

- Fix json payloads with sub hash/array/namedtupled. [#41](https://github.com/icyleaf/halite/issues/41) (thanks @[fusillicode](https://github.com/fusillicode))

## [0.7.2] (2018-09-14)

> Minor bugfix :bug:

### Changed

- **[breaking changing]** Renamed `#to_h` to `#to_flat_h` to avoid confict in `HTTP::Params` extension. [#39](https://github.com/icyleaf/halite/issues/39)

### Fixed

- Fix cast from NamedTuple(work: String) to Halite::Options::Type failed with params/json/form. [#38](https://github.com/icyleaf/halite/issues/38)

## [0.7.1] (2018-09-04)

### Changed

- Return empty hash for an empty named tuple.

### Fixed

- Fix send cookie during requesting in session mode. (thanks @[megatux](https://github.com/megatux))
- Fix pass current options instead of instance variable.
- Fix move named tuple extension to src path.

## [0.7.0] (2018-09-03)

> Features support :tada:

### Changed

- **[breaking changing]** Change instance `Halite::Client` with block behavior. [#33](https://github.com/icyleaf/halite/issues/33)
- **[breaking changing]** Renamed argument name `adapter` to `format` in `#logger` chainable method.
- Move logger into features.

### Added

- Add features (aka middleware) support, you can create monitor or interceptor. [#29](https://github.com/icyleaf/halite/issues/29)
- Add cache feature. [#24](https://github.com/icyleaf/halite/issues/24)
- Add `#logging` in chainable method.

### Fixed

- Add misisng `#request` method with headers, params, form, json, raw, ssl arguments.
- Fix do not overwrite default headers with exists one by using `Halite::Options.merge`.
- Fix append response to history only with redirect uri. (thanks @[j8r](https://github.com/j8r))
- Typo and correct words in README. (thanks @[megatux](https://github.com/megatux))

## [0.6.0] (2018-08-24)

> Improve performance with :see_no_evil:

### Changed

- **[breaking changing]** Set `logger` to nil when instance a `Halite::Options`, it throws a `Halite::Error` exception if enable `logging`.
- Change `Halite::Options` accepts argument inside. no effect for users. [#27](https://github.com/icyleaf/halite/pull/27)
- Wrap all exception class into a module, better for reading document.

### Fixed

- Fix always return `#` with `#full_path` if fragment not exists in `Halite::Request`.
- Fix always overwrite with default headers with `#merge` in `Halite::Options`

### Tested

- Compatibility with Crystal 0.26

## [0.5.0] (2018-07-03)

### Changed

- New logger system and json logger support, see [#19](https://github.com/icyleaf/halite/pull/19).
- **[breaking changing]** Change verb request behavior:
  - `get`, `head` only accepts `#params` argument.
  - `post`, `put`, `delete`, `patch`, `options` accepts `#params`, `#form`, `#json` and `#raw` arguments.

### Added

- Add request [#raw](https://github.com/icyleaf/halite/#raw-string) string support. [#20](https://github.com/icyleaf/halite/issues/20) (thanks @[wirrareka](https://github.com/wirrareka))

## [0.4.0] (2018-06-27)

### Changed

- **[breaking changing]** Remove `#mime_type` duplicate with `#content_type` in `Halite::Response`.
- Change write log file use append mode by default, it could be change by param.
- Change logger formatter to easy identify category(request/response).

### Added

- Add [#links](https://github.com/icyleaf/halite/#link-headers) to `Halite::Response` to fetch link headers.
- Add [#raise_for_status](https://github.com/icyleaf/halite/#raise-for-status-code) to `Halite::Response`.
- Support multiple files upload. [#14](https://github.com/icyleaf/halite/issues/14) (thanks @[BenDietze](https://github.com/BenDietze))
- Add `#to_raw` to `Halite::Response` to dump a raw of response. [#15](https://github.com/icyleaf/halite/issues/15) (thanks @[BenDietze](https://github.com/BenDietze))
- Support `OPTIONS` method (crystal 0.25.0+)
- Append write log to a file section to README.

### Fixed

- Stripped the filename in a `multipart/form-data` body. [#16](https://github.com/icyleaf/halite/issues/16) (thanks @[BenDietze](https://github.com/BenDietze))
- Fix `#domain` in `Halite::Request` with subdomain. [#17](https://github.com/icyleaf/halite/pull/17) (thanks @[007lva](https://github.com/007lva))
- Create missing directories when use path to write log to a file.

## [0.3.2] (2018-06-19)

### Fixed

Compatibility with Crystal 0.25

## [0.3.1] (2017-12-13)

### Added

- Set `Options.default_headers` to be public method.
- Accept tuples options in `Options.new`.
- Accept `follow`/`follow_strict` in `Options.new`.
- Accept options block in `Options.new`.
- Add logger during request and response (see [usage](README.md#logging)).
- Alias method `Options.read_timeout` to `Options::Timeout.read`.
- Alias method `Options.read_timeout=` to `Options::Timeout.read=`.
- Alias method `Options.connect_timeout` to `Options::Timeout.connect`.
- Alias method `Options.connect_timeout` to `Options::Timeout.connect=`.
- Alias method `Options.follow=` to `Options::Timeout.follow.hops=`.
- Alias method `Options.follow_strict` to `Options::Timeout.follow.strict`.
- Alias method `Options.follow_strict=` to `Options::Timeout.follow.strict=`.

### Fixed

- Fix store **Set-Cookies** in response and set **Cookies** in request in better way.
- Fix cant not set connect/read timeout in `Options.new`.
- Fix cant not overwrite default headers in `Options.new`.
- Fix `Options.clear!` was not clear everything and restore default headers.

## [0.2.0] (2017-11-28)

### Changed

- `HTTP::Headers#to_h` return string with each key if it contains one in array. ([commit#e057c47c](https://github.com/icyleaf/halite/commit/e057c47c4b587b27b2bae6871a1968299ce348f5))

### Added

- Add `Response#mime_type` method.
- Add `Response#history` method to support full history of redirections. ([#8](https://github.com/icyleaf/halite/issues/8))
- Add `Response#parse` method that it better body parser of response with json and write custom adapter for MIME type. ([#9](https://github.com/icyleaf/halite/issues/9))

### Fixed

- Fix issue to first char of redirect uri is not slash(/). ([#11](https://github.com/icyleaf/halite/issues/11))
- Fix raise unsafe verbs in strict mode.

## [0.1.5] (2017-10-11)

### Changed

- Only store cookies in Sessions shards. ([#7](https://github.com/icyleaf/halite/issues/7))

### Added

- Add `TLS/SSL` support (based on [HTTP::Client.new(uri : URI, tls = nil)](https://crystal-lang.org/api/0.23.1/HTTP/Client.html#new%28uri%3AURI%2Ctls%3Dnil%29-class-method)).
- Add `UnsupportedMethodError/UnsupportedSchemeError` exceptions.

### Fixed

- Timeout with redirection. ([#7](https://github.com/icyleaf/halite/issues/7))
- Compatibility with Crystal 0.24.0 (unreleased)

## [0.1.3] (2017-10-09)

### Changed

- Always instance a new Options with each request in chainable methods.

### Added

- Add `accept` method.

### Fixed

- Fix `follow`(redirect uri) with full uri and relative path.
- Fix always overwrite request headers with default values.
- Fix always shard same options in any new call. (it only valid in chainable methods)

## 0.1.2 (2017-09-18)

- First beta version.

[Unreleased]: https://github.com/icyleaf/halite/compare/v0.12.1...HEAD
[0.12.1]: https://github.com/icyleaf/halite/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/icyleaf/halite/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/icyleaf/halite/compare/v0.10.9...v0.11.0
[0.10.9]: https://github.com/icyleaf/halite/compare/v0.10.8...v0.10.9
[0.10.8]: https://github.com/icyleaf/halite/compare/v0.10.7...v0.10.8
[0.10.7]: https://github.com/icyleaf/halite/compare/v0.10.6...v0.10.7
[0.10.6]: https://github.com/icyleaf/halite/compare/v0.10.5...v0.10.6
[0.10.5]: https://github.com/icyleaf/halite/compare/v0.10.4...v0.10.5
[0.10.4]: https://github.com/icyleaf/halite/compare/v0.10.3...v0.10.4
[0.10.3]: https://github.com/icyleaf/halite/compare/v0.10.2...v0.10.3
[0.10.2]: https://github.com/icyleaf/halite/compare/v0.10.1...v0.10.2
[0.10.1]: https://github.com/icyleaf/halite/compare/v0.10.0...v0.10.1
[0.10.0]: https://github.com/icyleaf/halite/compare/v0.9.2...v0.10.0
[0.9.2]: https://github.com/icyleaf/halite/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/icyleaf/halite/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/icyleaf/halite/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/icyleaf/halite/compare/v0.7.5...v0.8.0
[0.7.5]: https://github.com/icyleaf/halite/compare/v0.7.4...v0.7.5
[0.7.4]: https://github.com/icyleaf/halite/compare/v0.7.3...v0.7.4
[0.7.3]: https://github.com/icyleaf/halite/compare/v0.7.2...v0.7.3
[0.7.2]: https://github.com/icyleaf/halite/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/icyleaf/halite/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/icyleaf/halite/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/icyleaf/halite/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/icyleaf/halite/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/icyleaf/halite/compare/v0.3.2...v0.4.0
[0.3.2]: https://github.com/icyleaf/halite/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/icyleaf/halite/compare/v0.2.0...v0.3.1
[0.2.0]: https://github.com/icyleaf/halite/compare/v0.1.5...v0.2.0
[0.1.5]: https://github.com/icyleaf/halite/compare/v0.1.3...v0.1.5
[0.1.3]: https://github.com/icyleaf/halite/compare/v0.1.2...v0.1.3


================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at icyleaf.cn@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]

[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/


================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Copyright (c) 2017-present icyleaf

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.


================================================
FILE: README.md
================================================
![halite-logo](https://github.com/icyleaf/halite/raw/master/halite-logo-small.png)

# Halite

[![Language](https://img.shields.io/badge/language-crystal-776791.svg)](https://github.com/crystal-lang/crystal)
[![Tag](https://img.shields.io/github/tag/icyleaf/halite.svg)](https://github.com/icyleaf/halite/blob/master/CHANGELOG.md)
[![Source](https://img.shields.io/badge/source-github-brightgreen.svg)](https://github.com/icyleaf/halite/)
[![Document](https://img.shields.io/badge/document-api-brightgreen.svg)](https://icyleaf.github.io/halite/)
[![Build Status](https://github.com/icyleaf/halite/workflows/Linux%20CI/badge.svg)](https://github.com/icyleaf/halite/actions?query=workflow%3A%22Linux+CI%22)

HTTP Requests with a chainable REST API, built-in sessions and middleware written by [Crystal](https://crystal-lang.org/).
Inspired from the **awesome** Ruby's [HTTP](https://github.com/httprb/http)/[RESTClient](https://github.com/rest-client/rest-client)
and Python's [requests](https://github.com/requests/requests).

Build in Crystal version `>= 1.0.0`, this document valid with latest commit.

## Index

<!-- TOC -->

- [Installation](#installation)
- [Usage](#usage)
  - [Making Requests](#making-requests)
  - [Passing Parameters](#passing-parameters)
    - [Query string parameters](#query-string-parameters)
    - [Form data](#form-data)
    - [File uploads (via form data)](#file-uploads-via-form-data)
    - [JSON data](#json-data)
    - [Raw String](#raw-string)
  - [Passing advanced options](#passing-advanced-options)
    - [Auth](#auth)
    - [User Agent](#user-agent)
    - [Headers](#headers)
    - [Cookies](#cookies)
    - [Redirects and History](#redirects-and-history)
    - [Timeout](#timeout)
  - [HTTPS](#https)
  - [Response Handling](#response-handling)
    - [Response Content](#response-content)
    - [JSON Content](#json-content)
    - [Parsing Content](#parsing-content)
    - [Binary Data](#binary-data)
  - [Error Handling](#error-handling)
    - [Raise for status code](#raise-for-status-code)
- [Middleware](#middleware)
  - [Write a simple feature](#write-a-simple-feature)
  - [Write a interceptor](#write-a-interceptor)
- [Advanced Usage](#advanced-usage)
  - [Configuring](#configuring)
  - [Endpoint](#endpoint)
  - [Sessions](#sessions)
  - [Streaming Requests](#streaming-requests)
  - [Logging](#logging)
  - [Local Cache](#local-cache)
  - [Link Headers](#link-headers)
<!-- /TOC -->

## Installation

Add this to your application's `shard.yml`:

```yaml
dependencies:
  halite:
    github: icyleaf/halite
```

## Usage

```crystal
require "halite"
```

### Making Requests

Make a GET request:

```crystal
# Direct get url
Halite.get("http://httpbin.org/get")

# Support NamedTuple as query params
Halite.get("http://httpbin.org/get", params: {
  language: "crystal",
  shard: "halite"
})

# Also support Hash as query params
Halite.get("http://httpbin.org/get", headers: {
    "Private-Token" => "T0k3n"
  }, params: {
    "language" => "crystal",
    "shard" => "halite"
  })

# And support chainable
Halite.header(private_token: "T0k3n")
      .get("http://httpbin.org/get", params: {
        "language" => "crystal",
        "shard" => "halite"
      })
```

See also all [chainable methods](https://icyleaf.github.io/halite/Halite/Chainable.html).

Many other HTTP methods are available as well:

- `get`
- `head`
- `post`
- `put`
- `delete`
- `patch`
- `options`

### Passing Parameters

#### Query string parameters

Use the `params` argument to add query string parameters to requests:

```crystal
Halite.get("http://httpbin.org/get", params: { "firstname" => "Olen", "lastname" => "Rosenbaum" })
```

#### Form data

Use the `form` argument to pass data serialized as form encoded:

```crystal
Halite.post("http://httpbin.org/post", form: { "firstname" => "Olen", "lastname" => "Rosenbaum" })
```

#### File uploads (via form data)

To upload files as if form data, construct the form as follows:

```crystal
Halite.post("http://httpbin.org/post", form: {
  "username" => "Quincy",
  "avatar" => File.open("/Users/icyleaf/quincy_avatar.png")
})
```

It is possible to upload multiple files:

```crystal
Halite.post("http://httpbin.org/post", form: {
  photos: [
    File.open("/Users/icyleaf/photo1.png"),
    File.open("/Users/icyleaf/photo2.png")
  ],
  album_name: "samples"
})
```

Or pass the name with `[]`:

```crystal
Halite.post("http://httpbin.org/post", form: {
  "photos[]" => [
    File.open("/Users/icyleaf/photo1.png"),
    File.open("/Users/icyleaf/photo2.png")
  ],
  "album_name" => "samples"
})
```

Multiple files can also be uploaded using both ways above, it depend on web server.

#### JSON data

Use the `json` argument to pass data serialized as body encoded:

```crystal
Halite.post("http://httpbin.org/post", json: { "firstname" => "Olen", "lastname" => "Rosenbaum" })
```

#### Raw String

Use the `raw` argument to pass raw string as body and set the `Content-Type` manually:

```crystal
# Set content-type to "text/plain" by default
Halite.post("http://httpbin.org/post", raw: "name=Peter+Lee&address=%23123+Happy+Ave&language=C%2B%2B")

# Set content-type manually
Halite.post("http://httpbin.org/post",
  headers: {
    "content-type" => "application/json"
  },
  raw: %Q{{"name":"Peter Lee","address":"23123 Happy Ave","language":"C++"}}
)
```

### Passing advanced options

#### Auth

Use the `#basic_auth` method to perform [HTTP Basic Authentication](http://tools.ietf.org/html/rfc2617) using a username and password:

```crystal
Halite.basic_auth(user: "user", pass: "p@ss").get("http://httpbin.org/get")

# We can pass a raw authorization header using the auth method:
Halite.auth("Bearer dXNlcjpwQHNz").get("http://httpbin.org/get")
```

#### User Agent

Use the `#user_agent` method to overwrite default one:

```crystal
Halite.user_agent("Crystal Client").get("http://httpbin.org/user-agent")
```

#### Headers

Here are two way to passing headers data:

##### 1. Use the `#headers` method

```crystal
Halite.headers(private_token: "T0k3n").get("http://httpbin.org/get")

# Also support Hash or NamedTuple
Halite.headers({ "private_token" => "T0k3n" }).get("http://httpbin.org/get")

# Or
Halite.headers({ private_token: "T0k3n" }).get("http://httpbin.org/get")
```

##### 2. Use the `headers` argument in the available request method:

```crystal
Halite.get("http://httpbin.org/anything" , headers: { private_token: "T0k3n" })

Halite.post("http://httpbin.org/anything" , headers: { private_token: "T0k3n" })
```

#### Cookies

##### Passing cookies in requests

The `Halite.cookies` option can be used to configure cookies for a given request:

```crystal
Halite.cookies(session_cookie: "6abaef100b77808ceb7fe26a3bcff1d0")
      .get("http://httpbin.org/headers")
```

##### Get cookies in requests

To obtain the cookies(cookie jar) for a given response, call the `#cookies` method:

```crystal
r = Halite.get("http://httpbin.org/cookies?set?session_cookie=6abaef100b77808ceb7fe26a3bcff1d0")
pp r.cookies
# => #<HTTP::Cookies:0x10dbed980 @cookies={"session_cookie" =>#<HTTP::Cookie:0x10ec20f00 @domain=nil, @expires=nil, @extension=nil, @http_only=false, @name="session_cookie", @path="/", @secure=false, @value="6abaef100b77808ceb7fe26a3bcff1d0">}>
```

#### Redirects and History

##### Automatically following redirects

The `Halite.follow` method can be used for automatically following redirects(Max up to 5 times):

```crystal
# Set the cookie and redirect to http://httpbin.org/cookies
Halite.follow
      .get("http://httpbin.org/cookies/set/name/foo")
```

##### Limiting number of redirects

As above, set over 5 times, it will raise a `Halite::TooManyRedirectsError`, but you can change less if you can:

```crystal
Halite.follow(2)
      .get("http://httpbin.org/relative-redirect/5")
```

##### Disabling unsafe redirects

It only redirects with `GET`, `HEAD` request and returns a `300`, `301`, `302` by default, otherwise it will raise a `Halite::StateError`.
We can disable it to set `:strict` to `false` if we want any method(verb) requests, in which case the `GET` method(verb) will be used for
that redirect:

```crystal
Halite.follow(strict: false)
      .post("http://httpbin.org/relative-redirect/5")
```

##### History

`Response#history` property list contains the `Response` objects that were created in order to complete the request.
The list is ordered from the oldest to most recent response.

```crystal
r = Halite.follow
          .get("http://httpbin.org/redirect/3")

r.uri
# => http://httpbin.org/get

r.status_code
# => 200

r.history
# => [
#      #<Halite::Response HTTP/1.1 302 FOUND {"Location" => "/relative-redirect/2" ...>,
#      #<Halite::Response HTTP/1.1 302 FOUND {"Location" => "/relative-redirect/1" ...>,
#      #<Halite::Response HTTP/1.1 302 FOUND {"Location" => "/get" ...>,
#      #<Halite::Response HTTP/1.1 200 OK    {"Content-Type" => "application/json" ...>
#    ]
```

**NOTE**: It contains the `Response` object if you use `history` and HTTP was not a `30x`, For example:

```crystal
r = Halite.get("http://httpbin.org/get")
r.history.size # => 0

r = Halite.follow
          .get("http://httpbin.org/get")
r.history.size # => 1
```

#### Timeout

By default, the Halite does not enforce timeout on a request.
We can enable per operation timeouts by configuring them through the chaining API.

The `connect` timeout is the number of seconds Halite will wait for our client to establish a connection to a remote server call on the socket.

Once our client has connected to the server and sent the HTTP request,
the `read` timeout is the number of seconds the client will wait for the server to send a response.

```crystal
# Separate set connect and read timeout
Halite.timeout(connect: 3.0, read: 2.minutes)
      .get("http://httpbin.org/anything")

# Boath set connect and read timeout
# The timeout value will be applied to both the connect and the read timeouts.
Halite.timeout(5)
      .get("http://httpbin.org/anything")
```

### HTTPS

The Halite supports HTTPS via Crystal's built-in OpenSSL module. All you have to do in order to use HTTPS is pass in an https://-prefixed URL.

To use client certificates, you can pass in a custom `OpenSSL::SSL::Context::Client` object containing the certificates you wish to use:

```crystal
tls = OpenSSL::SSL::Context::Client.new
tls.ca_certificates = File.expand_path("~/client.crt")
tls.private_key = File.expand_path("~/client.key")

Halite.get("https://httpbin.org/anything", tls: tls)
```

### Response Handling

After an HTTP request, `Halite::Response` object have several useful methods. (Also see the [API documentation](https://icyleaf.github.io/halite/Halite/Response.html)).

- **#body**: The response body.
- **#body_io**: The response body io only available in streaming requests.
- **#status_code**: The HTTP status code.
- **#content_type**: The content type of the response.
- **#content_length**: The content length of the response.
- **#cookies**: A `HTTP::Cookies` set by server.
- **#headers**: A `HTTP::Headers` of the response.
- **#links**: A list of `Halite::HeaderLink` set from headers.
- **#parse**: (return value depends on MIME type) parse the body using a parser defined for the `#content_type`.
- **#to_a**: Return a `Hash` of status code, response headers and body as a string.
- **#to_raw**: Return a raw of response as a string.
- **#to_s**: Return response body as a string.
- **#version**: The HTTP version.

#### Response Content

We can read the content of the server's response by call `#body`:

```crystal
r = Halite.get("http://httpbin.org/user-agent")
r.body
# => {"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"}
```

The `gzip` and `deflate` transfer-encodings are automatically decoded for you.
And requests will automatically decode content from the server. Most unicode charsets are seamlessly decoded.

#### JSON Content

There’s also a built-in a JSON adapter, in case you’re dealing with JSON data:

```crystal
r = Halite.get("http://httpbin.org/user-agent")
r.parse("json")
r.parse # simplily by default
# => {
# =>   "user-agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"
# => }
```

#### Parsing Content

`Halite::Response` has a MIME type adapter system that you can use a decoder to parse the content,
we can inherit `Halite::MimeTypes::Adapter` to make our adapter:

```crystal
# Define a MIME type adapter
class YAMLAdapter < Halite::MimeType::Adapter
  def decode(string)
    YAML.parse(string)
  end

  def encode(obj)
    obj.to_yaml
  end
end

# Register to Halite to invoke
Halite::MimeType.register YAMLAdapter.new, "application/x-yaml", "yaml", "yml"

# Test it!
r = Halite.get "https://raw.githubusercontent.com/icyleaf/halite/master/shard.yml"
r.parse("yaml") # or "yml"
# => {"name" => "halite", "version" => "0.4.0", "authors" => ["icyleaf <icyleaf.cn@gmail.com>"], "crystal" => "0.25.0", "license" => "MIT"}
```

#### Binary Data

Store binary data (eg, `application/octet-stream`) to file, you can use [streaming requests](#streaming-requests):

```crystal
Halite.get("https://github.com/icyleaf/halite/archive/master.zip") do |response|
  filename = response.filename || "halite-master.zip"
  File.open(filename, "w") do |file|
    IO.copy(response.body_io, file)
  end
end
```

### Error Handling

- For any status code, a `Halite::Response` will be returned.
- If request timeout, a `Halite::TimeoutError` will be raised.
- If a request exceeds the configured number of maximum redirections, a `Halite::TooManyRedirectsError` will raised.
- If request uri is http and configured tls context, a `Halite::RequestError` will raised.
- If request uri is invalid, a `Halite::ConnectionError`/`Halite::UnsupportedMethodError`/`Halite::UnsupportedSchemeError` will raised.

#### Raise for status code

If we made a bad request(a 4xx client error or a 5xx server error response), we can raise with `Halite::Response.raise_for_status`.

But, since our `status_code` was not `4xx` or `5xx`, it returns `nil` when we call it:

```crystal
urls = [
  "https://httpbin.org/status/404",
  "https://httpbin.org/status/500?foo=bar",
  "https://httpbin.org/status/200",
]

urls.each do |url|
  r = Halite.get url
  begin
    r.raise_for_status
    p r.body
  rescue ex : Halite::ClientError | Halite::ServerError
    p "[#{ex.status_code}] #{ex.status_message} (#{ex.class})"
  end
end

# => "[404] not found error with url: https://httpbin.org/status/404 (Halite::Exception::ClientError)"
# => "[500] internal server error error with url: https://httpbin.org/status/500?foo=bar (Halite::Exception::ServerError)"
# => ""
```

## Middleware

Halite now has middleware (a.k.a features) support providing a simple way to plug in intermediate custom logic
in your HTTP client, allowing you to monitor outgoing requests, incoming responses, and use it as an interceptor.

Available features:

- [Logging](#logging) (Yes, logging is based on feature, cool, aha!)
- [Local Cache](#local-cache) (local storage, speed up in development)

### Write a simple feature

Let's implement simple middleware that prints each request:

```crystal
class RequestMonister < Halite::Feature
  @label : String
  def initialize(**options)
    @label = options.fetch(:label, "")
  end

  def request(request) : Halite::Request
    puts @label
    puts request.verb
    puts request.uri
    puts request.body

    request
  end

  Halite.register_feature "request_monster", self
end
```

Then use it in Halite:

```crystal
Halite.use("request_monster", label: "testing")
      .post("http://httpbin.org/post", form: {name: "foo"})

# Or configure to client
client = Halite::Client.new do
  use "request_monster", label: "testing"
end

client.post("http://httpbin.org/post", form: {name: "foo"})

# => testing
# => POST
# => http://httpbin.org/post
# => name=foo
```

### Write a interceptor

Halite's killer feature is the **interceptor**, Use `Halite::Feature::Chain` to process with two result:

- `next`: perform and run next interceptor
- `return`: perform and return

So, you can intercept and turn to the following registered features.

```crystal
class AlwaysNotFound < Halite::Feature
  def intercept(chain)
    response = chain.perform
    response = Halite::Response.new(chain.request.uri, 404, response.body, response.headers)
    chain.next(response)
  end

  Halite.register_feature "404", self
end

class PoweredBy < Halite::Feature
  def intercept(chain)
    if response = chain.response
      response.headers["X-Powered-By"] = "Halite"
      chain.return(response)
    else
      chain
    end
  end

  Halite.register_feature "powered_by", self
end

r = Halite.use("404").use("powered_by").get("http://httpbin.org/user-agent")
r.status_code               # => 404
r.headers["X-Powered-By"]   # => Halite
r.body                      # => {"user-agent":"Halite/0.6.0"}
```

For more implementation details about the feature layer, see the [Feature](https://github.com/icyleaf/halite/blob/master/src/halite/feature.cr#L2) class and [examples](https://github.com/icyleaf/halite/tree/master/src/halite/features) and [specs](https://github.com/icyleaf/halite/blob/master/spec/spec_helper.cr#L23).

## Advanced Usage

### Configuring

Halite provides a traditional way to instance client, and you can configure any chainable methods with block:

```crystal
client = Halite::Client.new do
  # Set basic auth
  basic_auth "username", "password"

  # Enable logging
  logging true

  # Set timeout
  timeout 10.seconds

  # Set user agent
  headers user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"
end

# You also can configure in this way
client.accept("application/json")

r = client.get("http://httpbin.org/get")
```

### Endpoint

No more given endpoint per request, use `endpoint` will make the request URI shorter, you can set it in flexible way:

```crystal
client = Halite::Client.new do
  endpoint "https://gitlab.org/api/v4"
  user_agent "Halite"
end

client.get("users")       # GET https://gitlab.org/api/v4/users

# You can override the path by using an absolute path
client.get("/users")      # GET https://gitlab.org/users
```

### Sessions

As like [requests.Session()](http://docs.python-requests.org/en/master/user/advanced/#session-objects), Halite built-in session by default.

Let's persist some cookies across requests:

```crystal
client = Halite::Client.new
client.get("http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0")
client.get("http://httpbin.org/cookies")
# => 2018-06-25 18:41:05 +08:00 | request | GET    | http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0
# => 2018-06-25 18:41:06 +08:00 | response | 302    | http://httpbin.org/cookies/set?private_token=6abaef100b77808ceb7fe26a3bcff1d0 | text/html
# => <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
# => <title>Redirecting...</title>
# => <h1>Redirecting...</h1>
# => <p>You should be redirected automatically to target URL: <a href="/cookies">/cookies</a>.  If not click the link.
# => 2018-06-25 18:41:06 +08:00 | request | GET    | http://httpbin.org/cookies
# => 2018-06-25 18:41:07 +08:00 | response | 200    | http://httpbin.org/cookies | application/json
# => {"cookies":{"private_token":"6abaef100b77808ceb7fe26a3bcff1d0"}}
```

All it support with [chainable methods](https://icyleaf.github.io/halite/Halite/Chainable.html) in the other examples list in [requests.Session](http://docs.python-requests.org/en/master/user/advanced/#session-objects).

Note, however, that chainable methods will not be persisted across requests, even if using a session. This example will only send the cookies or headers with the first request, but not the second:

```crystal
client = Halite::Client.new
r = client.cookies("username": "foobar").get("http://httpbin.org/cookies")
r.body # => {"cookies":{"username":"foobar"}}

r = client.get("http://httpbin.org/cookies")
r.body # => {"cookies":{}}
```

If you want to manually add cookies, headers (even features etc) to your session, use the methods start with `with_` in `Halite::Options`
to manipulate them:

```crystal
r = client.get("http://httpbin.org/cookies")
r.body # => {"cookies":{}}

client.options.with_cookie("username": "foobar")
r = client.get("http://httpbin.org/cookies")
r.body # => {"cookies":{"username":"foobar"}}
```

### Streaming Requests

Similar to [HTTP::Client](https://crystal-lang.org/api/0.36.1/HTTP/Client.html#streaming) usage with a block,
you can easily use same way, but Halite returns a `Halite::Response` object:

```crystal
r = Halite.get("http://httpbin.org/stream/5") do |response|
  response.status_code                  # => 200
  response.body_io.each_line do |line|
    puts JSON.parse(line)               # => {"url" => "http://httpbin.org/stream/5", "args" => {}, "headers" => {"Host" => "httpbin.org", "Connection" => "close", "User-Agent" => "Halite/0.8.0", "Accept" => "*/*", "Accept-Encoding" => "gzip, deflate"}, "id" => 0_i64}
  end
end
```

> **Warning**:
>
> `body_io` is avaiabled as an `IO` and not reentrant safe. Might throws a "Nil assertion failed" exception if there is no data in the `IO`
(such like `head` requests). Calling this method multiple times causes some of the received data being lost.
>
> One more thing, use streaming requests the response will always [enable redirect](#redirects-and-history) automatically.

### Logging

Halite does not enable logging on each request and response too.
We can enable per operation logging by configuring them through the chaining API.

By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level.
You can configuring the following options:

- `logging`: Instance your `Halite::Logging::Abstract`, check [Use the custom logging](#use-the-custom-logging).
- `format`: Output format, built-in `common` and `json`, you can write your own.
- `file`: Write to file with path, works with `format`.
- `filemode`: Write file mode, works with `format`, by default is `a`. (append to bottom, create it if file is not exist)
- `skip_request_body`: By default is `false`.
- `skip_response_body`: By default is `false`.
- `skip_benchmark`: Display elapsed time, by default is `false`.
- `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`.

> **NOTE**: `format` (`file` and `filemode`) and `logging` are conflict, you can not use both.

Let's try with it:

```crystal
# Logging json request
Halite.logging
      .get("http://httpbin.org/get", params: {name: "foobar"})

# => 2018-06-25 18:33:14 +08:00 | request  | GET    | http://httpbin.org/get?name=foobar
# => 2018-06-25 18:33:15 +08:00 | response | 200    | http://httpbin.org/get?name=foobar | 381.32ms | application/json
# => {"args":{"name":"foobar"},"headers":{"Accept":"*/*","Accept-Encoding":"gzip, deflate","Connection":"close","Host":"httpbin.org","User-Agent":"Halite/0.3.2"},"origin":"60.206.194.34","url":"http://httpbin.org/get?name=foobar"}

# Logging image request
Halite.logging
      .get("http://httpbin.org/image/png")

# => 2018-06-25 18:34:15 +08:00 | request  | GET    | http://httpbin.org/image/png
# => 2018-06-25 18:34:15 +08:00 | response | 200    | http://httpbin.org/image/png | image/png

# Logging with options
Halite.logging(skip_request_body: true, skip_response_body: true)
      .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")})

# => 2018-08-28 14:33:19 +08:00 | request  | POST   | http://httpbin.org/post
# => 2018-08-28 14:33:21 +08:00 | response | 200    | http://httpbin.org/post | 1.61s | application/json
```

#### JSON-formatted logging

It has JSON formatted for developer friendly logging.

```
Halite.logging(format: "json")
      .get("http://httpbin.org/get", params: {name: "foobar"})
```

#### Write to a log file

```crystal
# Write plain text to a log file
Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a")))
Halite.logging(for: "halite.file", skip_benchmark: true, colorize: false)
      .get("http://httpbin.org/get", params: {name: "foobar"})

# Write json data to a log file
Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a")))
Halite.logging(format: "json", for: "halite.file")
      .get("http://httpbin.org/get", params: {name: "foobar"})

# Redirect *all* logging from Halite to a file:
Log.setup("halite", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a")))
```

#### Use the custom logging

Creating the custom logging by integration `Halite::Logging::Abstract` abstract class.
Here has two methods must be implement: `#request` and `#response`.

```crystal
class CustomLogging < Halite::Logging::Abstract
  def request(request)
    @logger.info { "| >> | %s | %s %s" % [request.verb, request.uri, request.body] }
  end

  def response(response)
    @logger.info { "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type] }
  end
end

# Add to adapter list (optional)
Halite::Logging.register "custom", CustomLogging.new

Halite.logging(logging: CustomLogging.new)
      .get("http://httpbin.org/get", params: {name: "foobar"})

# We can also call it use format name if you added it.
Halite.logging(format: "custom")
      .get("http://httpbin.org/get", params: {name: "foobar"})

# => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar
# => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json
```

### Local Cache

Local cache feature is caching responses easily with Halite through an chainable method that is simple and elegant
yet powerful. Its aim is to focus on the HTTP part of caching and do not worrying about how stuff stored, api rate limiting
even works without network(offline).

It has the following options:

- `file`: Load cache from file. it conflict with `path` and `expires`.
- `path`: The path of cache, default is "/tmp/halite/cache/"
- `expires`: The expires time of cache, default is never expires.
- `debug`: The debug mode of cache, default is `true`

With debug mode, cached response it always included some headers information:

- `X-Halite-Cached-From`: Cache source (cache or file)
- `X-Halite-Cached-Key`: Cache key with verb, uri and body (return with cache, not `file` passed)
- `X-Halite-Cached-At`:  Cache created time
- `X-Halite-Cached-Expires-At`: Cache expired time (return with cache, not `file` passed)

```crystal
Halite.use("cache").get "http://httpbin.org/anything"     # request a HTTP
r = Halite.use("cache").get "http://httpbin.org/anything" # request from local storage
r.headers                                                 # => {..., "X-Halite-Cached-At" => "2018-08-30 10:41:14 UTC", "X-Halite-Cached-By" => "Halite", "X-Halite-Cached-Expires-At" => "2018-08-30 10:41:19 UTC", "X-Halite-Cached-Key" => "2bb155e6c8c47627da3d91834eb4249a"}}
```

### Link Headers

Many HTTP APIs feature [Link headers](https://tools.ietf.org/html/rfc5988). GitHub uses
these for [pagination](https://developer.github.com/v3/#pagination) in their API, for example:

```crystal
r = Halite.get "https://api.github.com/users/icyleaf/repos?page=1&per_page=2"
r.links
# => {"next" =>
# =>   Halite::HeaderLink(
# =>    @params={},
# =>    @rel="next",
# =>    @target="https://api.github.com/user/17814/repos?page=2&per_page=2"),
# =>  "last" =>
# =>   Halite::HeaderLink(
# =>    @params={},
# =>    @rel="last",
# =>    @target="https://api.github.com/user/17814/repos?page=41&per_page=2")}

r.links["next"]
# => "https://api.github.com/user/17814/repos?page=2&per_page=2"

r.links["next"].params
# => {}
```

## Help and Discussion

You can browse the API documents:

https://icyleaf.github.io/halite/

You can browse the all chainable methods:

https://icyleaf.github.io/halite/Halite/Chainable.html

You can browse the Changelog:

https://github.com/icyleaf/halite/blob/master/CHANGELOG.md

If you have found a bug, please create a issue here:

https://github.com/icyleaf/halite/issues/new

## How to Contribute

Your contributions are always welcome! Please submit a pull request or create an issue to add a new question, bug or feature to the list.

All [Contributors](https://github.com/icyleaf/halite/graphs/contributors) are on the wall.

## You may also like

- [totem](https://github.com/icyleaf/totem) - Load and parse a configuration file or string in JSON, YAML, dotenv formats.
- [markd](https://github.com/icyleaf/markd) - Yet another markdown parser built for speed, Compliant to CommonMark specification.
- [poncho](https://github.com/icyleaf/poncho) - A .env parser/loader improved for performance.
- [popcorn](https://github.com/icyleaf/popcorn) - Easy and Safe casting from one type to another.
- [fast-crystal](https://github.com/icyleaf/fast-crystal) - 💨 Writing Fast Crystal 😍 -- Collect Common Crystal idioms.

## License

[MIT License](https://github.com/icyleaf/halite/blob/master/LICENSE) © icyleaf


================================================
FILE: shard.yml
================================================
name: halite
version: 0.12.1

authors:
  - icyleaf <icyleaf.cn@gmail.com>

crystal: ">= 0.36.1, < 2.0.0"

license: MIT


================================================
FILE: spec/fixtures/cache_file.json
================================================
{"name":"foo3"}


================================================
FILE: spec/halite/client_spec.cr
================================================
require "../spec_helper"
require "../support/mock_server"

describe Halite::Client do
  # It accepts all chainable methods, see spec/halite_spec.cr

  describe "#initialize" do
    it "should initial with nothing" do
      client = Halite::Client.new
      client.should be_a(Halite::Client)
    end

    it "should initial with options" do
      client = Halite::Client.new(headers: {
        user_agent: "Spec",
      })

      client.should be_a(Halite::Client)
      client.options.headers["User-Agent"].should eq("Spec")
    end

    it "should initial with block" do
      client = Halite::Client.new do
        headers(private_token: "token")
        timeout(read: 2.minutes, connect: 40)
      end

      client.should be_a(Halite::Client)
      client.options.headers.should be_a(HTTP::Headers)
      client.options.headers["Private-Token"].should eq("token")
      client.options.timeout.connect.should eq(40)
      client.options.timeout.read.should eq(120)
    end

    it "should initial with empty block" do
      client = Halite::Client.new { }
      client.should be_a(Halite::Client)
    end
  end

  describe "#endpoint" do
    it "should set String from arguments" do
      client = Halite::Client.new(endpoint: SERVER.endpoint)
      response = client.get("/")
      response.status_code.should eq(200)
    end

    it "should set String from block" do
      client = Halite::Client.new do
        endpoint SERVER.endpoint
      end

      response = client.get("")
      response.status_code.should eq(200)
    end

    it "should not overwrite the path of uri if path is empty string" do
      client = Halite::Client.new do
        endpoint "#{SERVER.endpoint}/redirect-301"
      end

      response = client.get("")
      response.status_code.should eq(301)
    end

    it "should always overwrite the path of uri if path starts with '/' char" do
      client = Halite::Client.new do
        endpoint "#{SERVER.endpoint}/redirect-301"
      end

      response = client.accept("application/json").get("/")
      response.status_code.should eq(200)
      response.to_s.should match(/json/)
    end

    it "should use default uri by each requests" do
      client = Halite::Client.new do
        endpoint SERVER.endpoint
      end

      response = client.get("anything", params: {"foo" => "bar"})
      response.parse["url"].should eq("/anything?foo=bar")

      # try again to make sure endpoint was exists.
      response = client.get("anything", params: {"foo" => "bar"})
      response.parse["url"].should eq("/anything?foo=bar")
    end

    it "should resolves uri" do
      client = Halite::Client.new do
        endpoint SERVER.endpoint
      end

      response = Halite.get("#{SERVER.endpoint}/params", params: {foo: "bar"})
      response.status_code.should eq(200)
      response.to_s.should eq("Params!")
    end
  end

  describe "#request" do
    %w[get post put delete head patch options].each do |verb|
      it "should easy to #{verb} request" do
        response = Halite::Client.new.request(verb, SERVER.endpoint)
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} request with hash or namedtuple" do
        response = Halite::Client.new.request(verb, SERVER.endpoint, params: {name: "foo"})
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} request with options" do
        response = Halite::Client.new.request(verb, SERVER.endpoint)
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} streaming request" do
        data = [] of JSON::Any
        Halite::Client.new.request(verb, SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"

          if verb != "head"
            while content = response.body_io.gets
              data << JSON.parse(content)
            end
          else
            expect_raises NilAssertionError do
              response.body_io
            end
          end
        end

        if verb != "head"
          data.size.should eq 2
          data.first.as_h["verb"].should eq verb.upcase
        else
          data.size.should eq 0
        end
      end
    end
  end

  describe "#sessions" do
    it "should store and send cookies" do
      client = Halite::Client.new

      # get Set-Cookies from server
      r = client.get SERVER.api("cookies")
      r.headers["Set-Cookie"].should eq("foo=bar")

      r.cookies.size.should eq(1)
      r.cookies["foo"].value.should eq("bar")

      # request with stored cookies
      r = client.get SERVER.api("get-cookies")
      r.headers.has_key?("Set-Cookie").should be_false
      r.cookies.size.zero?.should be_true
      r.parse("json").as_h["foo"].should eq("bar")
    end
  end

  describe "#multiple" do
    it "should use independent and share options" do
      client1 = Halite::Client.new do
        endpoint SERVER.api("/user_agent")
        user_agent "foo"
      end

      client2 = Halite::Client.new do
        endpoint SERVER.api("/")
        user_agent "bar"
      end

      r1 = client1.get("")
      r1.to_s.should eq("foo")

      r2 = client2.post("")
      r2.to_s.should eq("<!doctype html><body>Mock Server is running.</body></html>")
    end
  end
end


================================================
FILE: spec/halite/error_spec.cr
================================================
require "../spec_helper"

describe Halite::Exception do
  describe "#APIError" do
    it "should initial without arguments" do
      error = Halite::APIError.new
      error.message.should be_nil
      error.status_code.should be_nil
      error.status_message.not_nil!.should eq "unknown error"
    end

    it "should initial with message only" do
      message = "foobar"

      error = Halite::APIError.new(message)
      error.message.not_nil!.should eq message
      error.status_code.should be_nil
      error.status_message.not_nil!.should eq "foobar error"
    end

    it "should initial with message and status_code" do
      message = "foobar"
      status_code = 400

      error = Halite::APIError.new(message, status_code)
      error.message.not_nil!.should eq message
      error.status_code.not_nil!.should eq status_code
      error.status_message.not_nil!.should eq "bad request error"
    end

    it "should initial with full arguments" do
      message = "foobar"
      status_code = 400
      uri = URI.parse("https://www.example.com/get/foobar")

      error = Halite::APIError.new(message, status_code, uri)
      error.message.not_nil!.should eq message
      error.status_code.not_nil!.should eq status_code
      error.uri.not_nil!.should eq uri
      error.status_message.not_nil!.should eq "bad request error with url: #{uri}"
    end

    it "should initial without message" do
      status_code = 400
      uri = URI.parse("https://www.example.com/get/foobar")

      error = Halite::APIError.new(nil, status_code, uri)
      error.message.not_nil!.should eq "#{status_code} bad request error with url: #{uri}"
      error.status_code.not_nil!.should eq status_code
      error.uri.not_nil!.should eq uri
      error.status_message.not_nil!.should eq "bad request error with url: #{uri}"
    end
  end
end


================================================
FILE: spec/halite/ext/http_headers_encode_spec.cr
================================================
require "../../spec_helper"

describe HTTP::Headers do
  describe "#encode" do
    it "should accepts Hash(String, _)" do
      HTTP::Headers.encode({
        "foo"    => "bar",
        "number" => 1,
        "bool"   => false,
        "array"  => ["1", "2", "false"],
      }).size.should eq 4
    end

    it "should accepts NamedTuple" do
      HTTP::Headers.encode({
        foo:    "bar",
        number: 1,
        bool:   false,
        array:  ["1", "2", "false"],
      }).size.should eq 4
    end

    it "should accepts tuples as params" do
      HTTP::Headers.encode(foo: "bar", name: ["foo", "bar"]).size.should eq 2
    end

    it "should return as HTTP::Params" do
      HTTP::Headers.encode({} of String => String).class.should eq HTTP::Headers
    end

    it "accepts array to same key" do
      h = HTTP::Headers.encode(cookie: ["a=b", "c=d", "e=f"])
      h["Cookie"].should eq "a=b,c=d,e=f"
    end
  end

  describe "#to_flat_h" do
    flat_h = HTTP::Headers{"Accepts" => ["application/json", "text/html"], "Content-Type" => ["text/html"]}.to_flat_h
    flat_h["Accepts"].should eq(["application/json", "text/html"])
    flat_h["Content-Type"].should eq("text/html")
  end
end


================================================
FILE: spec/halite/ext/http_params_encode_spec.cr
================================================
require "../../spec_helper"

describe HTTP::Params do
  describe "#encode" do
    it "should encode hash to url-encoded query" do
      HTTP::Params.encode({
        "name" => "Lizeth Gusikowski",
      }).should eq "name=Lizeth+Gusikowski"
    end

    it "should encode array in key to url-encoded query" do
      HTTP::Params.encode({
        "skill" => ["ruby", "crystal"],
      }).should eq "skill=ruby&skill=crystal"
    end

    it "should encode hash in key to url-encoded query" do
      HTTP::Params.encode({
        "company" => {
          "name" => "Keeling Inc",
        },
      }).should eq "company%5Bname%5D=Keeling+Inc"
    end

    it "should extract file name to uri-encoded query" do
      HTTP::Params.encode({
        "avatar" => File.open("halite-logo-small.png"),
      }).should eq "avatar=halite-logo-small.png"
    end

    it "should encode named tupled in key to url-encoded query" do
      HTTP::Params.encode({
        name:    "Lizeth Gusikowski",
        company: {
          name: "Keeling Inc",
        },
        skill: ["ruby", "crystal"],
      }).should eq "name=Lizeth+Gusikowski&company=%7Bname%3A+%22Keeling+Inc%22%7D&skill=ruby&skill=crystal"

      HTTP::Params.encode(
        name: "Lizeth Gusikowski",
        company: {
          name: "Keeling Inc",
        },
        skill: ["ruby", "crystal"]
      ).should eq "name=Lizeth+Gusikowski&company=%7Bname%3A+%22Keeling+Inc%22%7D&skill=ruby&skill=crystal"
    end
  end
end


================================================
FILE: spec/halite/feature_spec.cr
================================================
require "../spec_helper"

describe Halite::Feature do
  it "should a empty feature" do
    feature = TestFeatures::Null.new
    feature.responds_to?(:request).should be_true
    feature.responds_to?(:response).should be_true
    feature.responds_to?(:intercept).should be_true
  end
end


================================================
FILE: spec/halite/features/cache_spec.cr
================================================
require "../../spec_helper"

private struct CacheStruct
  getter metadata, body, chain

  def initialize(@body : String, @chain : Halite::Feature::Chain, @metadata : Hash(String, JSON::Any)? = nil)
  end
end

private def cache_spec(cache, request, response, use_cache = false, wait_time : (Int32 | Time::Span)? = nil)
  FileUtils.rm_rf(cache.path)

  _chain = Halite::Feature::Chain.new(request, nil, Halite::Options.new) do
    response
  end

  if file = cache.file
    chain = cache.intercept(_chain)
    body = File.read_lines(file).join("\n")
    yield CacheStruct.new(body, chain)
  else
    key = Digest::MD5.hexdigest("#{request.verb}-#{request.uri}-#{request.body}")
    path = File.join(cache.path, key)
    metadata_file = File.join(path, "metadata.json")
    body_file = File.join(path, "#{key}.cache")

    cache.intercept(_chain) if use_cache

    if seconds = wait_time
      sleep seconds
    end

    chain = cache.intercept(_chain)

    Dir.exists?(path).should be_true
    File.file?(metadata_file).should be_true
    File.file?(body_file).should be_true

    metadata = JSON.parse(File.open(metadata_file)).as_h
    body = File.read_lines(body_file).join("\n")

    yield CacheStruct.new(body, chain, metadata)

    FileUtils.rm_rf(cache.path)
  end
end

describe Halite::Cache do
  it "should register a format" do
    Halite.has_feature?("cache").should be_true
    Halite.feature("cache").should eq(Halite::Cache)
  end

  describe "getters" do
    it "should default value" do
      feature = Halite::Cache.new
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should be_nil
      feature.debug.should be_true
    end

    it "should return setter value" do
      feature = Halite::Cache.new(path: "/tmp/cache", expires: 1.day, debug: false)
      feature.file.should be_nil
      feature.path.should eq("/tmp/cache")
      feature.expires.should eq(1.day)
      feature.debug.should be_false

      # expires accept Int32/Time::Span but return Time::Span
      feature = Halite::Cache.new(expires: 60)
      feature.file.should be_nil
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should eq(1.minutes)
      feature.debug.should be_true
    end
  end

  describe "intercept" do
    it "should cache to local storage" do
      body = {name: "foo"}.to_json
      request = Halite::Request.new("get", URI.parse(SERVER.api("/anything?q=halite1#result")), HTTP::Headers{"Accept" => "application/json"})
      response = Halite::Response.new(request.uri, 200, body, HTTP::Headers{"Content-Type" => "application/json", "Content-Length" => body.size.to_s})
      feature = Halite::Cache.new
      feature.file.should be_nil
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should be_nil
      feature.debug.should be_true

      # First return response on HTTP
      cache_spec(feature, request, response, use_cache: false) do |result|
        result.metadata.not_nil!["status_code"].should eq(200)
        result.metadata.not_nil!["headers"].as_h["Content-Type"].should eq("application/json")
        result.metadata.not_nil!["headers"].as_h["Content-Length"].should eq(response.body.size.to_s)
        result.body.should eq(response.body)
        result.chain.result.should eq(Halite::Feature::Chain::Result::Return)
        result.chain.response.should eq(response)
      end

      # Second return response on Cache
      cache_spec(feature, request, response, use_cache: true) do |result|
        result.metadata.not_nil!["status_code"].should eq(200)
        result.metadata.not_nil!["headers"].as_h["Content-Type"].should eq("application/json")
        result.metadata.not_nil!["headers"].as_h["Content-Length"].should eq(response.body.size.to_s)
        result.body.should eq(response.body)
        result.chain.result.should eq(Halite::Feature::Chain::Result::Return)

        result.chain.response.should_not be_nil
        result.chain.response.not_nil!.headers["X-Halite-Cached-From"].should eq("cache")
        result.chain.response.not_nil!.headers["X-Halite-Cached-Key"].should_not eq("")
        result.chain.response.not_nil!.headers["X-Halite-Cached-At"].should_not eq("")
        result.chain.response.not_nil!.headers["X-Halite-Cached-Expires-At"].should eq("None")
      end
    end

    it "should cache without debug mode" do
      body = {name: "foo1"}.to_json
      request = Halite::Request.new("get", URI.parse(SERVER.api("/anything?q=halite2#result")), HTTP::Headers{"Accept" => "application/json"})
      response = Halite::Response.new(request.uri, 200, body, HTTP::Headers{"Content-Type" => "application/json", "Content-Length" => body.size.to_s})
      feature = Halite::Cache.new(debug: false)
      feature.file.should be_nil
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should be_nil
      feature.debug.should be_false

      cache_spec(feature, request, response, use_cache: true) do |result|
        result.metadata.not_nil!["status_code"].should eq(200)
        result.metadata.not_nil!["headers"].as_h["Content-Type"].should eq("application/json")
        result.metadata.not_nil!["headers"].as_h["Content-Length"].should eq(response.body.size.to_s)
        result.body.should eq(response.body)
        result.chain.result.should eq(Halite::Feature::Chain::Result::Return)

        result.chain.response.should_not be_nil
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-From").should be_false
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-Key").should be_false
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-At").should be_false
      end
    end

    it "should return no cache if expired" do
      body = {name: "foo2"}.to_json
      request = Halite::Request.new("get", URI.parse(SERVER.api("/anything?q=halite3#result")), HTTP::Headers{"Accept" => "application/json"})
      response = Halite::Response.new(request.uri, 200, body, HTTP::Headers{"Content-Type" => "application/json", "Content-Length" => body.size.to_s})
      feature = Halite::Cache.new(expires: 1.milliseconds)
      feature.file.should be_nil
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should eq(1.milliseconds)
      feature.debug.should be_true

      cache_spec(feature, request, response, use_cache: true, wait_time: 500.milliseconds) do |result|
        result.metadata.not_nil!["status_code"].should eq(200)
        result.metadata.not_nil!["headers"].as_h["Content-Type"].should eq("application/json")
        result.metadata.not_nil!["headers"].as_h["Content-Length"].should eq(response.body.size.to_s)
        result.body.should eq(response.body)
        result.chain.result.should eq(Halite::Feature::Chain::Result::Return)

        result.chain.response.should_not be_nil
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-From").should be_false
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-Key").should be_false
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-At").should be_false
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-Expires-At").should be_false
      end
    end

    it "should load cache from file" do
      file = fixture_path("cache_file.json")
      body = load_fixture("cache_file.json")
      request = Halite::Request.new("get", URI.parse(SERVER.api("/anything?q=halite4#result")), HTTP::Headers{"Accept" => "application/json"})
      response = Halite::Response.new(request.uri, 200, body, HTTP::Headers{"Content-Type" => "application/json", "Content-Length" => body.size.to_s})
      feature = Halite::Cache.new(file: file)
      feature.file.should eq(file)
      feature.path.should eq(Halite::Cache::DEFAULT_PATH)
      feature.expires.should be_nil
      feature.debug.should be_true

      cache_spec(feature, request, response, file) do |result|
        result.metadata.should be_nil
        result.body.should eq(response.body)
        result.chain.result.should eq(Halite::Feature::Chain::Result::Return)

        result.chain.response.should_not be_nil
        result.chain.response.not_nil!.headers["X-Halite-Cached-From"].should eq("file")
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-Key").should be_false
        result.chain.response.not_nil!.headers["X-Halite-Cached-At"].should_not eq("")
        result.chain.response.not_nil!.headers.has_key?("X-Halite-Cached-Expires-At").should be_false
      end
    end
  end
end


================================================
FILE: spec/halite/features/logging_spec.cr
================================================
require "../../spec_helper"

private class NulleLogger < Halite::Logging::Abstract
  def request(request)
  end

  def response(response)
  end
end

describe Halite::Logging do
  describe "#register" do
    it "should register a format" do
      Halite::Logging.register "null", NulleLogger
      Halite::Logging.availables.includes?("null").should be_true
      Halite::Logging["null"].should eq(NulleLogger)
    end
  end

  describe "#initilize" do
    it "should use common as default logging" do
      logging = Halite::Logging.new
      logging.writer.should be_a(Halite::Logging::Common)
      logging.writer.skip_request_body.should be_false
      logging.writer.skip_response_body.should be_false
      logging.writer.skip_benchmark.should be_false
      logging.writer.colorize.should be_true
    end

    it "should use custom logging" do
      logging = Halite::Logging.new(logging: NulleLogger.new)
      logging.writer.should be_a(NulleLogger)
      logging.writer.skip_request_body.should be_false
      logging.writer.skip_response_body.should be_false
      logging.writer.skip_benchmark.should be_false
      logging.writer.colorize.should be_true
    end

    it "should use File IO" do
      with_tempfile("halite-features-logging") do |file|
        uri = URI.parse("https://httpbin.org/get")
        Log.setup("halite.spec.file", backend: Log::IOBackend.new(File.open(file, "w")))
        writer = Halite::Logging::Common.new(for: "halite.spec.file")
        logging = Halite::Logging.new(logging: writer)
        logging.writer.should be_a(Halite::Logging::Common)
        logging.request(Halite::Request.new("get", uri))
        logging.response(Halite::Response.new(
          uri,
          HTTP::Client::Response.new(status_code: 200, body: "foobar", headers: HTTP::Headers.encode({"Content-Type" => "text/plain; charset=utf-8"}))
        ))

        # waiting file writes
        sleep 1

        logs = File.read(file)
        logs.should contain("request")
        logs.should contain("response")
      end
    end
  end
end


================================================
FILE: spec/halite/header_link_spec.cr
================================================
require "../spec_helper"

private def parse_it(raw : String, uri : URI? = nil)
  Halite::HeaderLink.parse(raw, uri)
end

describe Halite::HeaderLink do
  it "should returns only url" do
    links = parse_it("http://example.net/bar")
    links.should be_a Hash(String, Halite::HeaderLink)
    links.not_nil!["http://example.net/bar"].rel.should eq "http://example.net/bar"
    links.not_nil!["http://example.net/bar"].target.should eq "http://example.net/bar"
    links.not_nil!["http://example.net/bar"].params.size.should eq 0
    links.not_nil!["http://example.net/bar"].to_s.should eq "http://example.net/bar"
  end

  it "should returns without 'rel' attribute" do
    links = parse_it("<http://example.net/foobar>;")
    links.should be_a Hash(String, Halite::HeaderLink)
    links.not_nil!["http://example.net/foobar"].rel.should eq "http://example.net/foobar"
    links.not_nil!["http://example.net/foobar"].target.should eq "http://example.net/foobar"

    links = parse_it(%Q{<http://example.net/foobar>; type="text/html"; })
    links.should be_a Hash(String, Halite::HeaderLink)
    links.not_nil!["http://example.net/foobar"].rel.should eq "http://example.net/foobar"
    links.not_nil!["http://example.net/foobar"].target.should eq "http://example.net/foobar"
    links.not_nil!["http://example.net/foobar"].params.size.should eq 1
    links.not_nil!["http://example.net/foobar"].params["type"].should eq "text/html"
  end

  it "should returns with relative path and none-given uri of response" do
    uri = URI.parse("http://sub.example.com/foo/bar")
    links = parse_it(%Q{</TheBook/chapter2>;rel="previous"}, uri)
    links.should be_a Hash(String, Halite::HeaderLink)
    links.not_nil!["previous"].rel.should eq "previous"

    target = uri.dup
    target.path = "/TheBook/chapter2"
    links.not_nil!["previous"].target.should eq target.to_s
  end

  it "should returns and keep the first value with multiple same attributes" do
    links = parse_it(%Q{<TheBook/chapter2>; rel="foo bar";title="Foo";rel="bar";title="Bar"})
    links.not_nil!.has_key?("foo bar").should be_true
    links.not_nil!.has_key?("bar").should be_false
    links.not_nil!["foo bar"].target.should eq "TheBook/chapter2"
    links.not_nil!["foo bar"].params["title"].should eq "Foo"
  end

  it "should return a list of links" do
    hash = parse_it(%Q{<https://api.github.com/user/repos?page=3&per_page=100>; rel="next"; title="Next Page", </>; rel="http://example.net/foo"})
    hash.should be_a Hash(String, Halite::HeaderLink)
    if links = hash
      links.has_key?("next").should be_true
      links["next"].rel.should eq "next"
      links["next"].target.should eq "https://api.github.com/user/repos?page=3&per_page=100"
      links["next"].params.size.should eq 1
      links["next"].params["title"].should eq "Next Page"
      links["next"].to_s.should eq "https://api.github.com/user/repos?page=3&per_page=100"

      links.has_key?("/").should be_false
      links["http://example.net/foo"].rel.should eq "http://example.net/foo"
      links["http://example.net/foo"].target.should eq "http://example.net/foo"
      links["http://example.net/foo"].params.size.should eq 0
      links["http://example.net/foo"].to_s.should eq "http://example.net/foo"
    end
  end
end


================================================
FILE: spec/halite/mime_type_spec.cr
================================================
require "../spec_helper"
require "yaml"

private class YAMLAdapter < Halite::MimeType::Adapter
  def decode(string)
    YAML.parse string
  end

  def encode(obj)
    obj.to_yaml
  end
end

describe Halite::MimeType do
  it "should register an adapter" do
    Halite::MimeType["yaml"]?.should be_nil
    Halite::MimeType["yml"]?.should be_nil

    Halite::MimeType.register YAMLAdapter.new, "application/x-yaml", "yaml", "yml"

    Halite::MimeType["yaml"].should be_a YAMLAdapter
    Halite::MimeType["yml"].should be_a YAMLAdapter
  end
end


================================================
FILE: spec/halite/mime_types/json_spec.cr
================================================
require "../../spec_helper"

private class Foo
end

describe Halite::MimeType::JSON do
  describe "#encode" do
    it "should work with to_json class" do
      json = Halite::MimeType::JSON.new
      json.encode({name: "foo"}).should eq(%Q{{"name":"foo"}})
    end
  end

  describe "#decode" do
    it "should work with json string" do
      json = Halite::MimeType::JSON.new
      json.decode(%Q{{"name": "foo"}}).should be_a(JSON::Any)
      json.decode(%Q{{"name": "foo"}}).should eq({"name" => "foo"})
    end
  end
end


================================================
FILE: spec/halite/options/follow_spec.cr
================================================
require "../../spec_helper"

describe Halite::Follow do
  describe "#initialize" do
    it "should work" do
      follow = Halite::Follow.new(1, false)
      follow.hops.should eq(1)
      follow.strict.should be_false
    end

    it "should set one argument" do
      follow = Halite::Follow.new(1)
      follow.hops.should eq(1)
      follow.strict.should be_true

      follow = Halite::Follow.new(strict: false)
      follow.hops.should eq(0)
      follow.strict.should be_false
    end
  end

  describe "setter" do
    it "should work" do
      follow = Halite::Follow.new
      follow.hops = 3
      follow.hops.should eq(3)

      follow.strict = false
      follow.strict.should be_false
    end
  end
end


================================================
FILE: spec/halite/options/timeout_spec.cr
================================================
require "../../spec_helper"

describe Halite::Timeout do
  describe "#initialize" do
    it "should set with Int32" do
      timeout = Halite::Timeout.new(1, 2, 3)
      timeout.connect.should eq(1.0)
      timeout.read.should eq(2)
      timeout.write.should eq(3)
    end

    it "should set with Float64" do
      timeout = Halite::Timeout.new(1.2, 3.4, 5.6)
      timeout.connect.should eq(1.2)
      timeout.read.should eq(3.4)
      timeout.write.should eq(5.6)
    end

    it "should set with Time::Span" do
      timeout = Halite::Timeout.new(1.seconds, 1.minutes, 1.hour)
      timeout.connect.should eq(1.0)
      timeout.read.should eq(60.0)
      timeout.write.should eq(3600.0)
    end

    it "should set different format" do
      timeout = Halite::Timeout.new(1, 1.minutes, 10.0)
      timeout.connect.should eq(1.0)
      timeout.read.should eq(60.0)
      timeout.write.should eq(10.0)

      timeout = Halite::Timeout.new(1.2, 1, Time::Span.new(seconds: 30, nanoseconds: 0))
      timeout.connect.should eq(1.2)
      timeout.read.should eq(1.0)
      timeout.write.should eq(30.0)
    end

    it "should set one argument" do
      timeout = Halite::Timeout.new(1)
      timeout.connect.should eq(1.0)
      timeout.read.should be_nil
      timeout.write.should be_nil

      timeout = Halite::Timeout.new(connect: 2)
      timeout.connect.should eq(2.0)
      timeout.read.should be_nil
      timeout.write.should be_nil

      timeout = Halite::Timeout.new(read: 3)
      timeout.connect.should be_nil
      timeout.read.should eq(3.0)

      timeout = Halite::Timeout.new(write: 3)
      timeout.connect.should be_nil
      timeout.read.should be_nil
      timeout.write.should eq(3.0)
    end
  end

  describe "setter" do
    it "should set with Int32" do
      timeout = Halite::Timeout.new
      timeout.connect = 3
      timeout.connect.should eq(3.0)

      timeout.read = 12
      timeout.read.should eq(12.0)

      timeout.write = 1
      timeout.write.should eq(1.0)
    end

    it "should set with Float64" do
      timeout = Halite::Timeout.new(1, 2)
      timeout.connect = 3.0
      timeout.connect.should eq(3.0)

      timeout.read = 12.0
      timeout.read.should eq(12.0)

      timeout.write = 1.0
      timeout.write.should eq(1.0)
    end

    it "should set with Time::Span" do
      timeout = Halite::Timeout.new(1, 2)
      timeout.connect = 3.seconds
      timeout.connect.should eq(3.0)

      timeout.read = 1.minutes
      timeout.read.should eq(60.0)

      timeout.write = 1.hour
      timeout.write.should eq(3600.0)
    end
  end
end


================================================
FILE: spec/halite/options_spec.cr
================================================
require "../spec_helper"

private class SimpleFeature < Halite::Feature
  def request(request)
    request
  end

  def response(response)
    response
  end

  Halite.register_feature "simple", self
end

private def test_options
  Halite::Options.new(
    endpoint: "https://spec.example.com",
    headers: {
      user_agent: "spec",
    },
    params: {"title" => "h1"},
    form: {"title" => "h2"},
    json: {"title" => "h3"},
    raw: "title=h4",
    connect_timeout: 1,
    read_timeout: 3.2,
    write_timeout: 5,
    follow: 2,
    follow_strict: false,
    tls: OpenSSL::SSL::Context::Client.new,
    features: {
      "logging" => Halite::Logging.new.as(Halite::Feature),
    }
  )
end

describe Halite::Options do
  describe "#initialize" do
    it "should initial with nothing" do
      options = Halite::Options.new
      options.should be_a(Halite::Options)

      options.endpoint.should be_nil

      options.headers.empty?.should be_true

      options.cookies.should be_a(HTTP::Cookies)
      options.cookies.size.should eq(0)

      options.timeout.should be_a(Halite::Timeout)
      options.timeout.connect.should be_nil
      options.timeout.read.should be_nil
      options.timeout.write.should be_nil
      options.connect_timeout.should be_nil
      options.read_timeout.should be_nil
      options.write_timeout.should be_nil

      options.follow.should be_a(Halite::Follow)
      options.follow.hops.should eq(Halite::Follow::DEFAULT_HOPS)
      options.follow.strict.should eq(Halite::Follow::STRICT)
      options.follow_strict.should eq(Halite::Follow::STRICT)

      options.tls.should be_nil
      options.params.should eq({} of String => Halite::Options::Type)
      options.form.should eq({} of String => Halite::Options::Type)
      options.json.should eq({} of String => Halite::Options::Type)
      options.raw.should be_nil
    end

    it "should initial with original" do
      options = Halite::Options.new(headers: {
        "private_token" => "token",
      },
        timeout: Halite::Timeout.new(connect: 3.2),
        endpoint: "https://example.com"
      )

      options.should be_a(Halite::Options)
      options.endpoint.should eq(URI.parse("https://example.com"))
      options.headers.should be_a(HTTP::Headers)
      options.headers["Private-Token"].should eq("token")
      options.timeout.connect.should eq(3.2)
      options.timeout.read.should be_nil
      options.timeout.write.should be_nil
    end

    it "should initial with quick setup" do
      endpoint = URI.parse("https://example.com")
      options = Halite::Options.new(headers: {
        private_token: "token",
      },
        connect_timeout: 1.minutes,
        endpoint: endpoint
      )

      options.should be_a(Halite::Options)
      options.endpoint.should eq(endpoint)
      options.headers.should be_a(HTTP::Headers)
      options.headers["Private-Token"].should eq("token")
      options.timeout.connect.should eq(60.0)
      options.timeout.read.should be_nil
      options.timeout.write.should be_nil
    end

    it "should overwrite default headers" do
      options = Halite::Options.new(
        headers: {
          user_agent: "spec",
        },
      )

      options.should be_a(Halite::Options)
      options.headers["User-Agent"].should eq("spec")
    end
  end

  describe "#merge!" do
    it "should works with Halite::Options" do
      old_options = test_options
      endpoint = old_options.endpoint
      new_tls = OpenSSL::SSL::Context::Client.new
      options = old_options.merge!(Halite::Options.new(
        headers: {
          user_agent: "new_spec",
        },
        params: {"title" => "1"},
        form: {"title" => "2"},
        json: {"title" => "3"},
        raw: "title=4",
        connect_timeout: 2,
        follow: 1,
        tls: new_tls,
        features: {
          "cache" => Halite::Cache.new.as(Halite::Feature),
        }
      ))

      # TODO: write_timeout
      old_options.endpoint.should eq(endpoint)
      old_options.headers.should eq(HTTP::Headers{"User-Agent" => "new_spec"})
      old_options.cookies.size.should eq(0)
      old_options.timeout.connect.should eq(2)
      old_options.timeout.read.should be_nil
      old_options.timeout.write.should be_nil
      old_options.connect_timeout.should eq(2)
      old_options.read_timeout.should be_nil
      old_options.write_timeout.should be_nil
      old_options.follow.hops.should eq(1)
      old_options.follow.strict.should be_true
      old_options.params.should eq({"title" => "1"})
      old_options.form.should eq({"title" => "2"})
      old_options.json.should eq({"title" => "3"})
      old_options.raw.should_not be_nil
      old_options.raw.not_nil!.should eq("title=4")
      old_options.tls.not_nil!.should eq(new_tls)
      options.features["logging"].should be_a(Halite::Logging)
      options.features["cache"].should be_a(Halite::Cache)

      options.endpoint.should eq(endpoint)
      options.headers.should eq(HTTP::Headers{"User-Agent" => "new_spec"})
      options.cookies.size.should eq(0)
      options.timeout.connect.should eq(2)
      options.timeout.read.should be_nil
      options.timeout.write.should be_nil
      options.connect_timeout.should eq(2)
      options.read_timeout.should be_nil
      options.write_timeout.should be_nil
      options.follow.hops.should eq(1)
      options.follow.strict.should be_true
      options.params.should eq({"title" => "1"})
      options.form.should eq({"title" => "2"})
      options.json.should eq({"title" => "3"})
      options.raw.should_not be_nil
      options.raw.not_nil!.should eq("title=4")
      options.tls.not_nil!.should eq(new_tls)
      options.features["logging"].should be_a(Halite::Logging)
      options.features["cache"].should be_a(Halite::Cache)
    end
  end

  describe "#merge" do
    it "should works with Halite::Options" do
      old_options = test_options
      endpoint = old_options.endpoint
      new_tls = OpenSSL::SSL::Context::Client.new
      options = old_options.merge(Halite::Options.new(
        endpoint: "https://new.exaple.com",
        headers: {
          user_agent: "new_spec",
        },
        params: {"title" => "1"},
        form: {"title" => "2"},
        json: {"title" => "3"},
        raw: "title=4",
        connect_timeout: 2,
        follow: 1,
        tls: new_tls,
        features: {
          "cache" => Halite::Cache.new.as(Halite::Feature),
        }
      ))

      old_options.endpoint.should eq(endpoint)
      old_options.headers.should eq(HTTP::Headers{"User-Agent" => "spec"})
      old_options.cookies.size.should eq(0)
      old_options.timeout.connect.should eq(1)
      old_options.timeout.read.should eq(3.2)
      old_options.timeout.write.should eq(5.0)
      old_options.follow.hops.should eq(2)
      old_options.follow.strict.should be_false
      old_options.params.should eq({"title" => "h1"})
      old_options.form.should eq({"title" => "h2"})
      old_options.json.should eq({"title" => "h3"})
      old_options.raw.should_not be_nil
      old_options.raw.not_nil!.should eq("title=h4")
      old_options.features.size.should eq(1)
      old_options.features["logging"].should be_a(Halite::Logging)
      old_options.tls.not_nil!.should_not eq(new_tls)

      options.endpoint.should eq(URI.parse("https://new.exaple.com"))
      options.headers.should eq(HTTP::Headers{"User-Agent" => "new_spec"})
      options.cookies.size.should eq(0)
      options.timeout.connect.should eq(2)
      options.timeout.read.should be_nil
      options.timeout.write.should be_nil
      options.connect_timeout.should eq(2)
      options.read_timeout.should be_nil
      options.write_timeout.should be_nil
      options.follow.hops.should eq(1)
      options.follow.strict.should be_true
      options.params.should eq({"title" => "1"})
      options.form.should eq({"title" => "2"})
      options.json.should eq({"title" => "3"})
      options.raw.should_not be_nil
      options.raw.not_nil!.should eq("title=4")
      options.tls.not_nil!.should eq(new_tls)
      options.features.size.should eq(2)
      options.features["logging"].should be_a(Halite::Logging)
      options.features["cache"].should be_a(Halite::Cache)
    end

    it "should overwrite exists value of headers from other" do
      options = Halite::Options.new(headers: {private_token: "foo"})
      new_options = options.merge(Halite::Options.new(headers: {private_token: "bar"}))
      new_options.headers.should eq(Halite::Options.new(headers: {private_token: "bar"}).headers)
    end

    it "should merge new headers from other" do
      options = Halite::Options.new(headers: {private_token: "foo"})
      new_options = options.merge(Halite::Options.new(headers: {content_type: "text/html"}))
      new_options.headers.should eq(Halite::Options.new(headers: {private_token: "foo", content_type: "text/html"}).headers)
    end
  end

  describe "#clear!" do
    options = test_options
    options.clear!
    options.endpoint.should be_nil
    options.headers.size.should eq(0)

    options.cookies.should be_a(HTTP::Cookies)
    options.cookies.size.should eq(0)

    options.timeout.should be_a(Halite::Timeout)
    options.timeout.connect.should be_nil
    options.timeout.read.should be_nil
    options.timeout.write.should be_nil
    options.connect_timeout.should be_nil
    options.read_timeout.should be_nil
    options.write_timeout.should be_nil

    options.follow.should be_a(Halite::Follow)
    options.follow.hops.should eq(Halite::Follow::DEFAULT_HOPS)
    options.follow.strict.should eq(Halite::Follow::STRICT)
    options.follow_strict.should eq(Halite::Follow::STRICT)

    options.tls.should be_nil
    options.params.should eq({} of String => Halite::Options::Type)
    options.form.should eq({} of String => Halite::Options::Type)
    options.json.should eq({} of String => Halite::Options::Type)
    options.raw.should be_nil

    options.features.should eq({} of String => Halite::Feature)
  end

  describe "#dup" do
    options = test_options
    new_options = options.dup

    new_options.endpoint = "https://example.com"
    new_options.endpoint.should eq(URI.parse("https://example.com"))
    options.endpoint.should eq(URI.parse("https://spec.example.com"))

    new_options.headers = HTTP::Headers.new
    new_options.headers.empty?.should be_true
    options.headers.size.should eq(1)

    cookies = HTTP::Cookies.new
    cookies << HTTP::Cookie.new("name", "foobar")
    new_options.cookies = cookies
    new_options.cookies.size.should eq(1)
    options.cookies.size.should eq 0

    timeout = Halite::Timeout.new(10, 20)
    new_options.timeout = timeout
    new_options.timeout.should eq(timeout)
    options.timeout.connect.should eq(test_options.timeout.connect)
    options.timeout.read.should eq(test_options.timeout.read)
    options.timeout.write.should eq(test_options.timeout.write)

    follow = Halite::Follow.new(6, true)
    new_options.follow = follow
    new_options.follow.should eq(follow)
    options.follow.hops.should eq(test_options.follow.hops)
    options.follow.strict.should eq(test_options.follow.strict)

    new_options.tls = nil
    new_options.tls.should be_nil
    options.tls.should_not be_nil

    data = {
      "name" => "foo".as(Halite::Options::Type),
    }

    new_options.params = data
    new_options.params.should eq(data)
    options.params.should eq(test_options.params)

    new_options.form = data
    new_options.form.should eq(data)
    options.form.should eq(test_options.form)

    new_options.json = data
    new_options.json.should eq(data)
    options.json.should eq(test_options.json)

    new_options.raw = "foobar"
    new_options.raw.not_nil!.should eq("foobar")
    options.raw.should eq(test_options.raw)

    features = {"cache" => Halite::Cache.new.as(Halite::Feature)}
    new_options.features = features
    new_options.features.size.should eq(1)
    new_options.features["cache"].should be_a(Halite::Cache)
    options.features.size.should eq(1)
    options.features["logging"].should be_a(Halite::Logging)

    new_options.logging = false
    new_options.logging.should be_false
    options.logging.should be_true
  end

  describe "#with_endpoint" do
    it "should overwrite String value" do
      options = Halite::Options.new
      options.with_endpoint("https://with.example.com")

      options.endpoint.should eq(URI.parse("https://with.example.com"))
    end

    it "should overwrite URI value" do
      endpoint = URI.parse("https://with.example.com")
      options = Halite::Options.new(endpoint: "https://new.example.com")
      options.with_endpoint(endpoint)

      options.endpoint.should eq(endpoint)
    end
  end

  describe "#with_headers" do
    it "should overwrite tupled headers" do
      options = Halite::Options.new(headers: {
        private_token: "token",
      })
      options = options.with_headers(private_token: "new", accept: "application/json")

      options.headers["Private-Token"].should eq("new")
      options.headers["Accept"].should eq("application/json")
    end

    it "should overwrite NamedTuped headers" do
      options = Halite::Options.new(headers: {
        private_token: "token",
      })
      options = options.with_headers(private_token: "new", accept: "application/json")

      options.headers["Private-Token"].should eq("new")
      options.headers["Accept"].should eq("application/json")
    end

    it "should overwrite Hash headers" do
      options = Halite::Options.new(headers: {
        private_token: "token",
      })
      options = options.with_headers(private_token: "new", accept: "application/json")

      options.headers["Private-Token"].should eq("new")
      options.headers["Accept"].should eq("application/json")
    end
  end

  describe "#with_cookies" do
    it "should overwrite tupled cookies" do
      options = Halite::Options.new(cookies: {
        "name" => "foo",
      })
      options = options.with_cookies(name: "bar")

      options.cookies["name"].value.should eq("bar")
    end

    it "should overwrite NamedTuple cookies" do
      options = Halite::Options.new(cookies: {
        "name" => "foo",
      })
      options = options.with_cookies({name: "bar"})

      options.cookies["name"].value.should eq("bar")
    end

    it "should overwrite Hash cookies" do
      options = Halite::Options.new(cookies: {
        "name" => "foo",
      })
      options = options.with_cookies({"name" => "bar"})

      options.cookies["name"].value.should eq("bar")
    end
  end

  describe "#with_timeout" do
    it "should overwrite timeout" do
      options = Halite::Options.new(timeout: Halite::Timeout.new(connect: 1, read: 3, write: 4))
      options = options.with_timeout(read: 4.minutes, connect: 1.2, write: 10)

      options.timeout.connect.should eq(1.2)
      options.timeout.read.should eq(4.minutes.to_f)
      options.timeout.write.should eq(10.0)
    end
  end

  describe "#with_follow" do
    it "should overwrite follow" do
      options = Halite::Options.new(follow: Halite::Follow.new(1, true))
      options = options.with_follow(follow: 5, strict: false)

      options.follow.hops.should eq(5)
      options.follow.strict.should be_false
    end
  end

  describe "#with_logging" do
    it "should overwrite logging with instance class" do
      options = Halite::Options.new.with_logging(logging: SimpleLogger.new)
      logging = options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(SimpleLogger)
    end

    it "should overwrite logging with format name" do
      Halite::Logging.register "simple", SimpleLogger

      options = Halite::Options.new.with_logging(format: "simple")
      logging = options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(SimpleLogger)
    end

    it "should became a file logging" do
      Halite::Logging.register "simple", SimpleLogger

      with_tempfile("halite_logger") do |file|
        Log.setup("halite.tempfile", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w")))
        options = Halite::Options.new.with_logging(format: "simple")
        logging = options.features["logging"].as(Halite::Logging)
        logging.writer.should be_a(SimpleLogger)
      end
    end

    it "throws an exception with unregister logging format" do
      expect_raises Halite::UnRegisterLoggerFormatError do
        Halite::Options.new.with_logging(format: "fake")
      end
    end
  end

  describe "#with_features" do
    it "should use a feature" do
      options = Halite::Options.new.with_features("logging")
      logging = options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(Halite::Logging::Common)
    end

    it "should use a feature with options" do
      options = Halite::Options.new.with_features("logging", logging: SimpleLogger.new)
      logging = options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(SimpleLogger)
    end

    it "should use multiple features" do
      Halite.register_feature "simple", SimpleFeature

      options = Halite::Options.new.with_features("logging", "simple")
      logging = options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(Halite::Logging::Common)

      simple = options.features["simple"].as(SimpleFeature)
      simple.should be_a(SimpleFeature)
    end

    it "throws an exception with unregister feature" do
      expect_raises Halite::UnRegisterFeatureError do
        Halite::Options.new.with_features("fake")
      end
    end
  end

  describe "#clear!" do
    it "should clear setted options" do
      options = Halite::Options.new(
        headers: {
          "private_token" => "token",
        },
        cookies: {
          "name" => "foo",
        },
        params: {"name" => "foo"},
        form: {"name" => "foo"},
        json: {"name" => "foo"},
        timeout: Halite::Timeout.new(1, 3),
        follow: Halite::Follow.new(4, false),
      )
      options.clear!

      options.headers.empty?.should be_true
      options.cookies.empty?.should be_true
      options.params.empty?.should be_true
      options.form.empty?.should be_true
      options.json.empty?.should be_true

      options.timeout.connect.nil?.should be_true
      options.timeout.read.nil?.should be_true
      options.timeout.write.nil?.should be_true

      options.follow.hops.should eq(Halite::Follow::DEFAULT_HOPS)
      options.follow.strict.should eq(Halite::Follow::STRICT)
    end
  end

  describe "alias methods" do
    context "endpoint" do
      it "getter" do
        options = Halite::Options.new(endpoint: "https://with.example.com")
        options.endpoint.should eq(URI.parse("https://with.example.com"))
      end

      it "setter" do
        endpoint_string = "https://with.example.com"
        endpoint = URI.parse(endpoint_string)
        options = Halite::Options.new(endpoint: endpoint_string)
        options.endpoint.should eq(endpoint)

        options = Halite::Options.new(endpoint: endpoint)
        options.endpoint.should eq(endpoint)

        options = Halite::Options.new
        options.endpoint = endpoint_string
        options.endpoint.should eq(endpoint)

        options = Halite::Options.new
        options.endpoint = endpoint
        options.endpoint.should eq(endpoint)
      end
    end

    context "connect_timeout alias to timeout.connect" do
      it "getter" do
        options = Halite::Options.new(timeout: Halite::Timeout.new(connect: 34))
        options.timeout.connect.should eq(34)
        options.connect_timeout.should eq(34)
      end

      it "setter" do
        options = Halite::Options.new

        options.timeout.connect = 12
        options.connect_timeout.should eq(12)
        options.timeout.connect.should eq(12)

        options.connect_timeout = 21
        options.connect_timeout.should eq(21)
        options.timeout.connect.should eq(21)
      end
    end

    context "read_timeout alias to timeout.read" do
      it "getter" do
        options = Halite::Options.new(timeout: Halite::Timeout.new(read: 34))
        options.read_timeout.should eq(34)
        options.timeout.read.should eq(34)
      end

      it "setter" do
        options = Halite::Options.new

        options.timeout.read = 12
        options.read_timeout.should eq(12)
        options.timeout.read.should eq(12)

        options.read_timeout = 21
        options.read_timeout.should eq(21)
        options.timeout.read.should eq(21)
      end
    end

    context "write_timeout alias to timeout.write" do
      it "getter" do
        options = Halite::Options.new(timeout: Halite::Timeout.new(write: 56))
        options.timeout.write.should eq(56)
        options.write_timeout.should eq(56)
      end

      it "setter" do
        options = Halite::Options.new

        options.timeout.write = 12
        options.write_timeout.should eq(12)
        options.timeout.write.should eq(12)

        options.write_timeout = 21
        options.write_timeout.should eq(21)
        options.timeout.write.should eq(21)
      end
    end

    context "only setter for follow alias to follow.hops" do
      it "setter" do
        options = Halite::Options.new

        options.follow = 2
        options.follow.hops.should eq(2)
      end

      it "getter" do
        options = Halite::Options.new(follow: Halite::Follow.new(3))

        # Can not return integer with follow
        options.follow.hops.should eq(3)
      end
    end

    context "follow_strict alias to follow.strict" do
      it "setter" do
        options = Halite::Options.new

        options.follow_strict = false
        options.follow.strict.should be_false

        options.follow.strict = true
        options.follow.strict.should be_true
      end

      it "getter" do
        options = Halite::Options.new(follow: Halite::Follow.new(strict: false))

        options.follow_strict.should be_false
        options.follow.strict.should be_false
      end
    end
  end
end


================================================
FILE: spec/halite/rate_limit_spec.cr
================================================
require "../spec_helper"

# private def parse_it(raw : String, uri : URI? = nil)
#   Halite::HeaderLinkParser.parse(raw, uri)
# end

describe Halite::RateLimit do
  describe "#parse" do
    it "should works with full arguments" do
      headers = HTTP::Headers{
        "X-RateLimit-Limit"     => "5000",
        "X-RateLimit-Remaining" => "4991",
        "X-RateLimit-Reset"     => "1613727325",
      }
      subject = Halite::RateLimit.parse(headers)
      subject.should be_a Halite::RateLimit
      subject.not_nil!.limit.should eq 5000
      subject.not_nil!.remaining.should eq 4991
      subject.not_nil!.reset.should eq 1613727325
    end

    it "should works with optional arguments" do
      headers = HTTP::Headers{
        "X-RateLimit-Limit" => "5000",
      }
      subject = Halite::RateLimit.parse(headers)
      subject.should be_a Halite::RateLimit
      subject.not_nil!.limit.should eq 5000
      subject.not_nil!.remaining.should be_nil
      subject.not_nil!.reset.should be_nil
    end

    it "should not works without any headers" do
      headers = HTTP::Headers.new
      subject = Halite::RateLimit.parse(headers)
      subject.should be_nil
    end
  end

  describe "#new" do
    it "should works with full arguments" do
      subject = Halite::RateLimit.new(5000, 4991, 1613727325)
      subject.limit.should eq 5000
      subject.remaining.should eq 4991
      subject.reset.should eq 1613727325
    end

    it "should works with optional arguments" do
      subject = Halite::RateLimit.new(nil, nil, nil)
      subject.limit.should be_nil
      subject.remaining.should be_nil
      subject.reset.should be_nil
    end
  end
end


================================================
FILE: spec/halite/redirector_spec.cr
================================================
require "../spec_helper"

private def request
  Halite::Request.new("head", URI.parse("http://example.com/foo?bar=baz"))
end

def response(uri : URI, status_code = 200, headers = {} of String => String, body = "")
  Halite::Response.new(
    uri,
    HTTP::Client::Response.new(status_code: status_code, body: body, headers: HTTP::Headers.encode(headers))
  )
end

def redirector(request, response, strict = true, max_hops = 5)
  Halite::Redirector.new(request, response, max_hops, strict)
end

def simple_response(status_code = Int32, body = "", headers = {} of String => String | Array(String))
  Halite::Response.new(
    URI.new("http://example.com"),
    HTTP::Client::Response.new(status_code: status_code, body: body, headers: HTTP::Headers.encode(headers))
  )
end

def redirect_response(status_code, location)
  simple_response status_code, "", {"Location" => location}
end

describe Halite::Redirector do
  describe "#strict" do
    it "should be true by default" do
      redirector(request, response(request.uri)).strict.should eq true
    end
  end

  describe "#max_hops" do
    it "should be 5 by default" do
      redirector(request, response(request.uri)).max_hops.should eq 5
    end
  end

  describe "#perform" do
    it "fails with TooManyRedirectsError if max hops reached" do
      res = ->(req : Halite::Request) { redirect_response(301, "#{req.uri}/1") }
      expect_raises Halite::TooManyRedirectsError do
        redirector(request, res.call(request)).perform do |prev_req|
          redirect_response(301, "#{prev_req.uri}/1")
        end
      end
    end

    it "fails with EndlessRedirectError if endless loop detected" do
      res = redirect_response 301, request.uri
      expect_raises Halite::EndlessRedirectError do
        redirector(request, res).perform do |_|
          res
        end
      end
    end

    it "fails with StateError if there were no Location header" do
      res = simple_response 301
      expect_raises Halite::StateError do
        redirector(request, res).perform do |_|
          res
        end
      end
    end

    it "returns first non-redirect response" do
      hops = [
        redirect_response(301, "http://example.com/1"),
        redirect_response(301, "http://example.com/2"),
        redirect_response(301, "http://example.com/3"),
        simple_response(200, "foo"),
        redirect_response(301, "http://example.com/4"),
        simple_response(200, "bar"),
      ]

      res = redirector(request, hops.shift).perform { hops.shift }
      res.to_s.should eq "foo"
    end

    context "following 300/301/302 redirect" do
      context "with strict mode" do
        it "it follows with original verb if it's safe" do
          req = Halite::Request.new "get", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 300, "http://example.com/1"

          redirector(req, res, true).perform do |prev_req|
            prev_req.verb.should eq "GET"
            simple_response 200
          end
        end

        it "raises StateError if original request was PUT" do
          req = Halite::Request.new "put", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 300, "http://example.com/1"
          expect_raises Halite::StateError do
            redirector(req, res, true).perform { |_| simple_response 200 }
          end
        end

        it "raises StateError if original request was POST" do
          req = Halite::Request.new "post", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 301, "http://example.com/1"
          expect_raises Halite::StateError do
            redirector(req, res, true).perform { |_| simple_response 200 }
          end
        end

        it "raises StateError if original request was DELETE" do
          req = Halite::Request.new "delete", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 302, "http://example.com/1"
          expect_raises Halite::StateError do
            redirector(req, res, true).perform { |_| simple_response 200 }
          end
        end
      end

      context "without strict mode" do
        it "it follows with original verb if it's safe" do
          req = Halite::Request.new "get", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 300, "http://example.com/1"

          redirector(req, res, false).perform do |prev_req|
            prev_req.verb.should eq "GET"
            simple_response 200
          end
        end

        it "raises StateError if original request was PUT" do
          req = Halite::Request.new "put", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 300, "http://example.com/1"
          redirector(req, res, false).perform do |prev_req|
            prev_req.verb.should eq "GET"
            simple_response 200
          end
        end

        it "raises StateError if original request was POST" do
          req = Halite::Request.new "post", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 301, "http://example.com/1"
          redirector(req, res, false).perform do |prev_req|
            prev_req.verb.should eq "GET"
            simple_response 200
          end
        end

        it "raises StateError if original request was DELETE" do
          req = Halite::Request.new "delete", URI.parse("http://example.com/foo?bar=baz")
          res = redirect_response 302, "http://example.com/1"
          redirector(req, res, false).perform do |prev_req|
            prev_req.verb.should eq "GET"
            simple_response 200
          end
        end
      end
    end

    context "following 303 redirect" do
      it "follows with HEAD if original request was HEAD" do
        req = Halite::Request.new "head", URI.parse("http://example.com/foo?bar=baz")
        res = redirect_response 303, "http://example.com/1"

        redirector(req, res).perform do |prev_req|
          prev_req.verb.should eq "HEAD"
          simple_response 200
        end
      end

      it "follows with GET if original request was GET" do
        req = Halite::Request.new "get", URI.parse("http://example.com/foo?bar=baz")
        res = redirect_response 303, "http://example.com/1"

        redirector(req, res).perform do |prev_req|
          prev_req.verb.should eq "GET"
          simple_response 200
        end
      end

      it "follows with GET if original request was neither GET nor HEAD" do
        req = Halite::Request.new "post", URI.parse("http://example.com/foo?bar=baz")
        res = redirect_response 303, "http://example.com/1"

        redirector(req, res).perform do |prev_req|
          prev_req.verb.should eq "GET"
          simple_response 200
        end
      end
    end

    context "following 307 redirect" do
      it "follows with original request's verb" do
        req = Halite::Request.new "post", URI.parse("http://example.com/foo?bar=baz")
        res = redirect_response 307, "http://example.com/1"

        redirector(req, res).perform do |prev_req|
          prev_req.verb.should eq "POST"
          simple_response 200
        end
      end
    end

    context "following 308 redirect" do
      it "follows with original request's verb" do
        req = Halite::Request.new "post", URI.parse("http://example.com/foo?bar=baz")
        res = redirect_response 308, "http://example.com/1"

        redirector(req, res).perform do |prev_req|
          prev_req.verb.should eq "POST"
          simple_response 200
        end
      end
    end
  end
end


================================================
FILE: spec/halite/request_spec.cr
================================================
require "../spec_helper"

private def request
  Halite::Request.new(
    "get",
    URI.parse("http://example.com/foo/bar?q=halite#result"),
    HTTP::Headers{"Accept" => "text/html"},
  )
end

describe Halite::Request do
  describe "#verb" do
    it "provides a #verb getter with upcase" do
      request.verb.should eq "GET"
    end
  end

  describe "#scheme" do
    it "provides a #scheme getter" do
      request.scheme.should eq "http"
    end
  end

  describe "#headers" do
    it "provides a given headers" do
      request.headers["Accept"].should eq "text/html"
    end

    it "could not set header with key and value" do
      request.headers["Via"] = "Halite"
      request.headers["Via"]?.should eq nil
    end
  end

  describe "#domain" do
    it "return `URI` with the scheme, user, password, port and host combined" do
      request.domain.to_s.should eq "http://example.com"
    end

    context "when subdomain and path are the same" do
      it "return `URI` with the scheme, user, password, port and host combined" do
        Halite::Request.new("get", URI.parse("https://login.example.com/login")).domain.to_s.should eq "https://login.example.com"
      end
    end
  end

  describe "#full_path" do
    it "provides a full_path" do
      request.full_path.should eq "/foo/bar?q=halite#result"
    end
  end

  describe "#body" do
    it "provides a body" do
      request.body.should eq ""
    end
  end

  describe "#redirect" do
    it "should return a new request" do
      request = Halite::Request.new("GET", URI.parse("http://httpbin.com/redirect/3"), headers: HTTP::Headers{"Host" => "httpbin.com"})
      new_request = request.redirect("http://httpbin.com/redirect/2")
      new_request.uri.to_s.should eq("http://httpbin.com/redirect/2")
      new_request.headers.has_key?("Host").should be_false
      new_request.verb.should eq("GET")
      request.uri.to_s.should eq("http://httpbin.com/redirect/3")
      request.verb.should eq("GET")
      request.headers.has_key?("Host").should be_true
    end

    it "should return a new request without Host" do
      request = Halite::Request.new("POST", URI.parse("http://httpbin.com/redirect/3"))
      new_request = request.redirect("http://httpbin.com/redirect/2", "GET")
      new_request.uri.to_s.should eq("http://httpbin.com/redirect/2")
      new_request.verb.should eq("GET")
      new_request.headers.has_key?("Host").should be_false
      request.uri.to_s.should eq("http://httpbin.com/redirect/3")
      request.verb.should eq("POST")
      request.headers.has_key?("Host").should be_false
    end
  end

  describe "raises" do
    it "should throws an exception with not allowed request method" do
      expect_raises Halite::UnsupportedMethodError, "Unknown method: TRACE" do
        Halite::Request.new("trace", URI.parse("http://httpbin.org/get"))
      end
    end

    it "should throws an exception without scheme part of URI" do
      expect_raises Halite::UnsupportedSchemeError, "Missing scheme: example.com" do
        Halite::Request.new("get", URI.parse("example.com"))
      end
    end

    it "should throws an exception with not allowed scheme part of URI" do
      expect_raises Halite::UnsupportedSchemeError, "Unknown scheme: ws" do
        Halite::Request.new("get", URI.parse("ws://example.com"))
      end
    end
  end
end


================================================
FILE: spec/halite/response_spec.cr
================================================
require "../spec_helper"

private URL         = "http://example.com"
private STATUS_CODE = 200
private HEADERS     = HTTP::Headers{"Content-Type" => "text/plain; charset=utf-8"}
private BODY        = "hello world"
private COOKIES     = "foo=bar; domain=example.com"

private def response(url = URL, status_code = STATUS_CODE, headers = HEADERS, body = BODY)
  Halite::Response.new(
    URI.parse(url),
    HTTP::Client::Response.new(status_code: status_code, body: body, headers: headers)
  )
end

private def empty_response
  Halite::Response.new(
    URI.parse(URL),
    HTTP::Client::Response.new(status_code: 404, body: "", headers: HTTP::Headers.new)
  )
end

describe Halite::Response do
  describe "to_a" do
    it "returns a Rack-like array" do
      response.to_a.should eq([STATUS_CODE, HEADERS, BODY])
    end
  end

  describe "#content_length" do
    it "should without Content-Length header" do
      response.content_length.should be_nil
    end

    it "should return content length with number" do
      r = response(headers: HTTP::Headers{"Content-Length" => "5"})
      r.content_length.should eq 5
    end

    it "should return invalid Content-Length" do
      r = response(headers: HTTP::Headers{"Content-Length" => "foo"})
      expect_raises ArgumentError do
        r.content_length
      end
    end
  end

  describe "#cookies" do
    it "should HTTP::Cookies class" do
      r = response(headers: HTTP::Headers{"Set-Cookie" => COOKIES})
      r.cookies.class.should eq HTTP::Cookies
      r.cookies["foo"].class.should eq HTTP::Cookie
      r.cookies["foo"].value.should eq "bar"
      r.cookies["foo"].domain.should eq "example.com"
    end
  end

  describe "#content_type" do
    it "should return nil with empty headers" do
      empty_response.content_type.should be_nil
    end

    it "should return with string with contains headers" do
      response.content_type.should eq "text/plain"
    end
  end

  describe "#links" do
    # NOTE: more specs in `header_link_spec.cr`.
    it "should return nil without Link Header" do
      response.links.should eq nil
    end

    it "should return a list of links" do
      r = response(headers: HTTP::Headers{"Link" => %Q{<https://api.github.com/user/repos?page=3&per_page=100>; rel="next"; title="Next Page", </>; rel="http://example.net/foo"}})
      r.links.should be_a Hash(String, Halite::HeaderLink)
      if links = r.links
        links.has_key?("next").should be_true
        links["next"].rel.should eq "next"
        links["next"].target.should eq "https://api.github.com/user/repos?page=3&per_page=100"
        links["next"].params.size.should eq 1
        links["next"].params["title"].should eq "Next Page"
        links["next"].to_s.should eq "https://api.github.com/user/repos?page=3&per_page=100"

        links.has_key?("/").should be_false
        links["http://example.net/foo"].rel.should eq "http://example.net/foo"
        links["http://example.net/foo"].target.should eq "http://example.net/foo"
        links["http://example.net/foo"].params.size.should eq 0
        links["http://example.net/foo"].to_s.should eq "http://example.net/foo"
      end
    end
  end

  describe "#rate_limit" do
    # NOTE: more specs in `rate_limit_spec.cr`.
    it "should return nil without RateLimit Header" do
      response.rate_limit.should eq nil
    end

    it "should return rate limit" do
      r = response(headers: HTTP::Headers{
        "X-RateLimit-Limit"     => "5000",
        "X-RateLimit-Remaining" => "4991",
        "X-RateLimit-Reset"     => "1613727325",
      })

      r.rate_limit.should be_a Halite::RateLimit
      r.rate_limit.not_nil!.limit.should eq 5000
      r.rate_limit.not_nil!.remaining.should eq 4991
      r.rate_limit.not_nil!.reset.should eq 1613727325
    end
  end

  describe "#raise_for_status" do
    it "should returns nil when status_code not range in (400..599)" do
      response.raise_for_status.should be_nil
    end

    (400..499).each do |code|
      it "throws an Halite::ClientError if status_code is #{code}" do
        expect_raises Halite::ClientError do
          response(status_code: code).raise_for_status
        end
      end
    end

    (500..599).each do |code|
      it "throws an Halite::ServerError if status_code is #{code}" do
        expect_raises Halite::ServerError do
          response(status_code: code).raise_for_status
        end
      end
    end
  end

  describe "#parse" do
    context "with known content type" do
      it "returns parsed body" do
        r = response(headers: HTTP::Headers{"Content-Type" => "application/json;charset=utf-8"}, body: %q{{"foo":"bar"}})
        r.parse.should eq({"foo" => "bar"})
      end
    end

    context "with empty content type" do
      it "raises Halite::UnRegisterMimeTypeError" do
        r = response(headers: HTTP::Headers{"Content-Type" => ""})
        expect_raises Halite::Error do
          r.parse
        end
      end
    end

    context "without content type" do
      it "raises Halite::UnRegisterMimeTypeError" do
        r = response(headers: HTTP::Headers{"Etag" => "123123123"})
        expect_raises Halite::Error do
          r.parse
        end
      end
    end

    context "with unknown content type" do
      it "raises Halite::UnRegisterMimeTypeError" do
        r = response(headers: HTTP::Headers{"Content-Type" => "application/html"})
        expect_raises Halite::UnRegisterMimeTypeError do
          r.parse
        end
      end
    end

    context "with explicitly given mime type" do
      it "ignores mime_type of response" do
        r = response(headers: HTTP::Headers{"Content-Type" => "application/html; charset=utf-8"}, body: %q{{"foo":"bar"}})
        r.parse("application/json").should eq({"foo" => "bar"})
      end

      it "supports MIME type aliases" do
        r = response(headers: HTTP::Headers{"Content-Type" => "application/html; charset=utf-8"}, body: %q{{"foo":"bar"}})
        r.parse("json").should eq({"foo" => "bar"})
      end
    end
  end

  describe "#inspect" do
    it "returns human-friendly response representation" do
      response.inspect.should eq %q{#<Halite::Response HTTP/1.1 200 OK {"Content-Type" => "text/plain; charset=utf-8"}>}
    end
  end
end


================================================
FILE: spec/halite_spec.cr
================================================
require "./spec_helper"

private def without_timezone(&block)
  with_timezone(nil, &block)
end

private def with_timezone(timezone : String? = nil, &block)
  current_timezone = ENV["TZ"]?
  restore_timezone = false

  if current_timezone && timezone.nil?
    restore_timezone = true
    ENV.delete("TZ")
  end

  if timezone
    restore_timezone = true
    ENV["TZ"] = timezone.not_nil!
  end

  block.call

  ENV["TZ"] = current_timezone if restore_timezone
end

describe Halite::Helper do
  describe "#timestamp" do
    it "should use utc timezone as default location" do
      without_timezone do
        ENV["TZ"]?.should be_nil
        t = Time.utc(2021, 2, 10, 22, 5, 13)
        Halite::Helper.to_rfc3339(t).should eq "2021-02-10T22:05:13Z"
      end
    end

    it "should use given timezone" do
      without_timezone do
        ENV["TZ"]?.should be_nil
        t = Time.utc(2021, 2, 10, 22, 5, 13)
        timezone = "Asia/Shanghai"
        Halite::Helper.to_rfc3339(t, timezone: timezone).should eq "2021-02-11T06:05:13+08:00"
      end
    end

    it "should use `TZ` timezone from ENV" do
      timezone = "Asia/Shanghai"
      with_timezone(timezone) do
        ENV["TZ"].should eq timezone
        t = Time.utc(2021, 2, 10, 22, 5, 13)
        Halite::Helper.to_rfc3339(t).should eq "2021-02-11T06:05:13+08:00"
      end
    end

    it "should overwrite given timezone" do
      timezone = "Asia/Shanghai"
      with_timezone(timezone) do
        ENV["TZ"].should eq timezone
        t = Time.utc(2021, 2, 10, 22, 5, 13)
        Halite::Helper.to_rfc3339(t, timezone: "Europe/Berlin").should eq "2021-02-10T23:05:13+01:00"
      end
    end
  end
end

describe Halite do
  describe ".new" do
    it "returns a instance class" do
      client = Halite::Client.new
      client.should be_a(Halite::Client)
      client.options.should be_a(Halite::Options)
    end
  end

  describe ".get" do
    context "loading a simple uri" do
      it "should easy to request" do
        response = Halite.get(SERVER.endpoint)
        response.to_s.should match(/<!doctype html>/)
      end
    end

    context "with query string parameters" do
      it "should easy to request" do
        response = Halite.get(SERVER.api("params"), params: {foo: "bar"})
        response.to_s.should eq("Params!")
      end
    end

    context "with query string parameters in the URI and opts hash" do
      it "includes both" do
        response = Halite.get("#{SERVER.endpoint}/multiple-params?foo=bar", params: {baz: "quux"})
        response.to_s.should eq("More Params!")
      end
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.headers("via": "foo").get(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "GET"
        data.first.as_h["headers"].as_h["Via"].should eq "foo"
      end
    end

    context "with headers" do
      it "is easy" do
        response = Halite.accept("application/json").get(SERVER.endpoint)
        response.to_s.should match(/json/)
      end

      it "is auth" do
        user = "halite"
        password = "p@ssword"
        secret = Base64.strict_encode("#{user}:#{password}")
        response = Halite.auth(secret).get(SERVER.api("auth"))
        response.to_s.should eq(secret)
      end

      it "is basic auth" do
        user = "halite"
        password = "p@ssword"
        credentials = Base64.strict_encode("#{user}:#{password}")
        response = Halite.basic_auth(user, password).get(SERVER.api("auth"))
        response.to_s.should eq("Basic #{credentials}")
      end
    end

    context "loading binary data" do
      it "is a png file" do
        response = Halite.get SERVER.api("image")
        response.headers["Content-Type"].should eq "image/png"
        response.filename.should eq "logo.png"
      end

      it "with streaming" do
        original_path = File.expand_path("../../halite-logo.png", __FILE__)
        Halite.get SERVER.api("image") do |response|
          File.open(original_path, "r") do |original_file|
            while byte = response.body_io.read_byte
              original_file.read_byte.should eq byte
            end
          end
        end
      end
    end

    context "with a large request body" do
      [16_000, 16_500, 17_000, 34_000, 68_000].each do |size|
        [0, rand(0..100), rand(100..1000)].each do |fuzzer|
          context "with a #{size} body and #{fuzzer} of fuzzing" do
            it "returns a large body" do
              characters = ("A".."Z").to_a
              form = Hash(String, String).new.tap { |obj| (size + fuzzer).times { |i| obj[i.to_s] = characters[i % characters.size] } }
              response = Halite.post SERVER.api("echo-body"), form: form
              response_body = HTTP::Params.encode(form)

              response.to_s.should eq(response_body)
              response.content_length.should eq(response_body.bytesize)
            end
          end
        end

        context "with `.timeout`" do
          [nil, 10, 10.0, 10.seconds].each do |timeout|
            it "writes the whole body with #{timeout.inspect}" do
              body = "“" * 1_000_000
              response = Halite.timeout(timeout).post SERVER.api("echo-body"), raw: body

              response.to_s.should eq(body)
              response.content_length.should eq(body.bytesize)
            end
          end

          it "writes the whole body with apiece arguments" do
            body = "“" * 1_000_000
            response = Halite.timeout(10, 10.0, 10.seconds).post SERVER.api("echo-body"), raw: body

            response.to_s.should eq(body)
            response.content_length.should eq(body.bytesize)
          end
        end
      end
    end

    context "fetching rate-limit headers" do
      it "should easy to read" do
        response = Halite.get(SERVER.api("rate-limit"))
        response.headers["X-RateLimit-Limit"].should eq "6000"
        response.headers["X-RateLimit-Remaining"].should eq "5998"
        response.headers["X-RateLimit-Reset"].should eq "1613727325"

        response.rate_limit.should be_a Halite::RateLimit
        response.rate_limit.not_nil!.limit.should eq 6000
        response.rate_limit.not_nil!.remaining.should eq 5998
        response.rate_limit.not_nil!.reset.should eq 1613727325
      end
    end
  end

  describe ".post" do
    context "loading a simple form data" do
      it "should easy to request with form data" do
        response = Halite.post(SERVER.api("form"), form: {example: "testing-form"})
        response.to_s.should contain("example: testing-form")
      end

      it "should easy to request with raw string" do
        response = Halite.post(SERVER.api("form"), raw: "example=testing-form")
        response.to_s.should contain("example: testing-form")
      end

      it "should easy to request with json data" do
        response = Halite.post(SERVER.api("form"), json: {"job" => {"title" => ["foo", "bar"], "info" => {gender: "male"}}})
        response.to_s.should contain(%Q({"job":{"title":["foo","bar"],"info":{"gender":"male"}}}))
      end
    end

    context "uploading file" do
      it "should easy upload only file" do
        response = Halite.post(SERVER.api("upload"), form: {file: File.open("./src/halite.cr")})
        body = response.parse.as_h
        params = body["params"].as_h
        files = body["files"].as_h

        params.size.should eq 0

        files.size.should eq 1
        files["file"]?.should be_a JSON::Any
        files["file"].as_h["filename"].should eq "halite.cr"
      end

      it "should easy upload file with other form data" do
        response = Halite.post(SERVER.api("upload"), form: {file: File.open("./src/halite.cr"), "name": "foobar"})
        body = response.parse.as_h
        params = body["params"].as_h
        files = body["files"].as_h

        params.size.should eq 1
        params["name"].should eq "foobar"

        files.size.should eq 1
        files["file"]?.should be_a JSON::Any
        files["file"].as_h["filename"].should eq "halite.cr"
      end

      it "should easy upload multiple files" do
        response = Halite.post(SERVER.api("upload"), form: {avatar: [File.open("halite-logo.png"), File.open("halite-logo-small.png")]})
        body = response.parse.as_h
        params = body["params"].as_h
        files = body["files"].as_h

        params.size.should eq 0
        files.size.should eq 1
        files["avatar"]?.should be_a JSON::Any
        files["avatar"].as_a.size.should eq 2
        files["avatar"].as_a[0].as_h["filename"].should eq "halite-logo.png"
        files["avatar"].as_a[1].as_h["filename"].should eq "halite-logo-small.png"
      end

      it "should easy upload multiple files with other form data" do
        response = Halite.post(SERVER.api("upload"), form: {
          avatar: [File.open("halite-logo.png"), File.open("halite-logo-small.png")],
          name:   "foobar",
        })
        body = response.parse.as_h
        params = body["params"].as_h
        files = body["files"].as_h

        params.size.should eq 1
        params["name"].should eq "foobar"

        files.size.should eq 1
        files["avatar"]?.should be_a JSON::Any
        files["avatar"].as_a.size.should eq 2
        files["avatar"].as_a[0].as_h["filename"].should eq "halite-logo.png"
        files["avatar"].as_a[1].as_h["filename"].should eq "halite-logo-small.png"
      end
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.post(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "POST"
      end
    end
  end

  describe ".put" do
    it "should easy to request" do
      response = Halite.put SERVER.endpoint
      response.status_code.should eq(200)
      response.content_type.should match(/html/)
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.put(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "PUT"
      end
    end
  end

  describe ".delete" do
    it "should easy to request" do
      response = Halite.delete SERVER.endpoint
      response.status_code.should eq(200)
      response.content_type.should match(/html/)
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.delete(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "DELETE"
      end
    end
  end

  describe ".patch" do
    it "should easy to request" do
      response = Halite.patch SERVER.endpoint
      response.status_code.should eq(200)
      response.content_type.should match(/html/)
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.patch(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "PATCH"
      end
    end
  end

  describe ".head" do
    it "should easy to request" do
      response = Halite.head SERVER.endpoint
      response.status_code.should eq(200)
      response.content_type.should match(/html/)
    end
  end

  describe ".options" do
    it "should easy to request" do
      response = Halite.options SERVER.endpoint
      response.status_code.should eq(200)
      response.content_type.should match(/html/)
    end

    context "streaming" do
      it "is easy" do
        data = [] of JSON::Any
        Halite.options(SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"
          while content = response.body_io.gets
            data << JSON.parse(content)
          end
        end

        data.size.should eq 2
        data.first.as_h["verb"].should eq "OPTIONS"
      end
    end
  end

  describe ".request" do
    %w[get post put delete head patch options].each do |verb|
      it "should easy to #{verb} request" do
        response = Halite.request(verb, SERVER.endpoint)
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} request with hash or namedtuple" do
        response = Halite.request(verb, SERVER.endpoint, params: {name: "foo"})
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} request with options" do
        response = Halite.request(verb, SERVER.endpoint, Halite::Options.new)
        response.status_code.should eq(200)
      end

      it "should easy to #{verb} streaming request" do
        data = [] of JSON::Any
        Halite.request(verb, SERVER.api("stream?n=2")) do |response|
          response.status_code.should eq 200
          response.headers["Transfer-Encoding"].should eq "chunked"

          if verb != "head"
            while content = response.body_io.gets
              data << JSON.parse(content)
            end
          else
            expect_raises NilAssertionError do
              response.body_io
            end
          end
        end

        if verb != "head"
          data.size.should eq 2
          data.first.as_h["verb"].should eq verb.upcase
        else
          data.size.should eq 0
        end
      end
    end

    it "throws an exception with non-support method" do
      expect_raises Halite::UnsupportedMethodError do
        Halite.request("abc", SERVER.endpoint)
      end
    end

    it "throws an exception with non-support scheme" do
      expect_raises Halite::UnsupportedSchemeError do
        Halite.request("get", "ws://example.com/abc")
      end
    end

    it "throws an exception without scheme" do
      expect_raises Halite::UnsupportedSchemeError do
        Halite.request("get", "example.com/abc")
      end
    end
  end

  describe ".follow" do
    context "without redirects" do
      it "should return empty history" do
        response = Halite.get(SERVER.api("/"))
        response.history.size.should eq(0)
      end
    end

    context "with redirects" do
      it "should return one history with non-redirect url" do
        response = Halite.follow.get(SERVER.api("/"))
        response.history.size.should eq(1)
        response.to_s.should match(/<!doctype html>/)
      end

      it "should easy for 301 with full uri" do
        response = Halite.follow.get(SERVER.api("redirect-301"))
        response.history.size.should eq(2)
        response.to_s.should match(/<!doctype html>/)
      end

      it "should easy for 301 with relative path" do
        response = Halite.follow.get(SERVER.api("redirect-301"), params: {"relative_path" => true})
        response.history.size.should eq(2)
        response.to_s.should match(/<!doctype html>/)
      end

      it "should easy for 301 with relative path which is not include slash" do
        response = Halite.follow.get(SERVER.api("redirect-301"), params: {"relative_path_without_slash" => true})
        response.history.size.should eq(2)
        response.to_s.should eq("hello")
      end

      it "should easy for 302" do
        response = Halite.follow.get(SERVER.api("redirect-302"))
        response.history.size.should eq(2)
        response.to_s.should match(/<!doctype html>/)
      end

      it "should store full history" do
        times = 5
        response = Halite.follow.get("#{SERVER.endpoint}/multi-redirect?n=#{times}")
        response.history.class.should eq Array(Halite::Response)
        response.history.size.should eq(times + 1)
      end
    end
  end

  describe ".endpoint" do
    it "sets endpoint with String" do
      endpoint = "https://example.com"
      client = Halite.endpoint(endpoint)
      client.options.endpoint.should eq(URI.parse(endpoint))
    end

    it "sets endpoint with String" do
      endpoint = URI.parse("https://example.com")
      client = Halite.endpoint(endpoint)
      client.options.endpoint.should eq(endpoint)
    end
  end

  describe ".auth" do
    it "sets Authorization header to the given value" do
      client = Halite.auth("abc")
      client.options.headers["Authorization"].should eq("abc")
    end
  end

  describe ".basic_auth" do
    it "sets Authorization header with proper BasicAuth value" do
      client = Halite.basic_auth(user: "foo", pass: "bar")
      client.options.headers["Authorization"].should match(%r{^Basic [A-Za-z0-9+/]+=*$})
    end
  end

  describe ".user_agent" do
    it "uses default user agent" do
      r = Halite.get SERVER.api("user_agent")
      r.body.should eq(Halite::Request::USER_AGENT)
    end

    it "sets user agent" do
      user_agent = "Awesome Halite Client"
      client = Halite.user_agent(user_agent)
      client.options.headers["User-Agent"].should eq(user_agent)

      r = client.get SERVER.api("user_agent")
      r.body.should eq(user_agent)
    end
  end

  describe ".timeout" do
    context "without timeout type" do
      it "sets given timeout options" do
        client = Halite.timeout(connect: 12, read: 6, write: 36)
        client.options.timeout.connect.should eq(12)
        client.options.timeout.read.should eq(6)
        client.options.timeout.write.should eq(36)
      end
    end
  end

  describe ".cookies" do
    it "passes correct `Cookie` header" do
      response = Halite.cookies(abc: "def").get(SERVER.api("cookies"))
      response.to_s.should eq("abc: def")
    end

    it "properly works with cookie jars from response" do
      cookies = Halite.get(SERVER.api("cookies")).cookies
      response = Halite.cookies(cookies).get(SERVER.api("cookies"))
      response.to_s.should eq("foo: bar")
    end

    it "properly merges cookies" do
      cookies = Halite.get(SERVER.api("cookies")).cookies
      response = Halite.cookies(foo: 123, bar: 321).cookies(cookies).get(SERVER.api("cookies"))
      response.to_s.should contain("foo: bar\nbar: 321")
    end
  end

  describe ".logging" do
    it "should use logging" do
      client = Halite.logging
      client.options.features.has_key?("logging").should be_true
      client.options.features["logging"].should be_a(Halite::Logging)
      logging = client.options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(Halite::Logging::Common)
      logging.writer.skip_request_body.should be_false
      logging.writer.skip_response_body.should be_false
      logging.writer.skip_benchmark.should be_false
      logging.writer.colorize.should be_true
    end

    it "sets logging with format" do
      client = Halite.logging(format: "json", skip_response_body: true)
      client.options.features.has_key?("logging").should be_true
      client.options.features["logging"].should be_a(Halite::Logging)
      logging = client.options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(Halite::Logging::JSON)
      logging.writer.skip_request_body.should be_false
      logging.writer.skip_response_body.should be_true
      logging.writer.skip_benchmark.should be_false
      logging.writer.colorize.should be_true
    end

    it "sets logging into file" do
      with_tempfile("halite-spec-logging") do |file|
        Log.setup("halite.spec.file", backend: Log::IOBackend.new(File.open(file, "a")))
        client = Halite.logging(for: "halite.spec.file", skip_response_body: true)
        client.options.features.has_key?("logging").should be_true
        client.options.features["logging"].should be_a(Halite::Logging)
        logging = client.options.features["logging"].as(Halite::Logging)
        logging.writer.should be_a(Halite::Logging::Common)
        logging.writer.skip_request_body.should be_false
        logging.writer.skip_response_body.should be_true
        logging.writer.skip_benchmark.should be_false
        logging.writer.colorize.should be_true

        client.get SERVER.endpoint

        # waiting file writes
        sleep 1

        logs = File.read(file)
        logs.should contain("request")
        logs.should contain("response")
        logs.should_not contain("<!doctype html><body>Mock Server is running.</body></html>")
      end
    end

    it "sets logging with custom logging" do
      client = Halite.logging(logging: SimpleLogger.new(skip_benchmark: true))
      client.options.features.has_key?("logging").should be_true
      client.options.features["logging"].should be_a(Halite::Logging)
      logging = client.options.features["logging"].as(Halite::Logging)
      logging.writer.should be_a(SimpleLogger)
      logging.writer.skip_request_body.should be_false
      logging.writer.skip_response_body.should be_false
      logging.writer.skip_benchmark.should be_true
      logging.writer.colorize.should be_true
    end
  end

  describe ".use" do
    describe "built-in features" do
      it "sets given feature name" do
        client = Halite.use("logging")
        client.options.features.has_key?("logging").should be_true
        client.options.features["logging"].should be_a(Halite::Logging)
        logging = client.options.features["logging"].as(Halite::Logging)
        logging.writer.should be_a(Halite::Logging::Common)
        logging.writer.skip_request_body.should be_false
        logging.writer.skip_response_body.should be_false
        logging.writer.skip_benchmark.should be_false
        logging.writer.colorize.should be_true
      end

      it "sets logging with logging" do
        client = Halite.use("logging", logging: Halite::Logging::JSON.new(skip_request_body: true, colorize: false))
        client.options.features.has_key?("logging").should be_true
        client.options.features["logging"].should be_a(Halite::Logging)
        logging = client.options.features["logging"].as(Halite::Logging)
        logging.writer.should be_a(Halite::Logging::JSON)
        logging.writer.skip_request_body.should be_true
        logging.writer.skip_response_body.should be_false
        logging.writer.skip_benchmark.should be_false
        logging.writer.colorize.should be_false

        # Restore
        Colorize.on_tty_only!
      end
    end

    describe "custom features" do
      it "should modify the headers of request and response" do
        response = Halite.use("append_headers").get(SERVER.api("/anything?a=b"))
        response.headers["X-Powered-By"].should eq("Halite")
        response.parse.as_h["headers"]["X-API-Limit"].should eq("60")
      end

      it "should mock response with interceptor" do
        response = Halite.use("mock").get(SERVER.api("/anything?a=b"))
        response.status_code.should eq(400)
        response.body.should eq("mock")
      end

      describe "enable multiple interceptors" do
        it "should call next intercept" do
          response = Halite.use("404").use("powered_by").get(SERVER.api("/anything?a=b"))
          response.status_code.should eq(404)
          response.headers["X-Powered-By"].should eq("Halite")
          response.body.should_not eq("")
        end

        it "should return on first interceptor" do
          response = Halite.use("mock").use("404").get(SERVER.api("/anything?a=b"))
          response.status_code.should eq(400)
          response.body.should eq("mock")
        end
      end
    end
  end

  describe "raise" do
    it "should throws a Halite::ConnectionError exception with not exist uri" do
      expect_raises Halite::ConnectionError do
        Halite.get("http://404-not_found.xyz/")
      end
    end

    it "should throws a Halite::ConnectionError exception with illegal port" do
      expect_raises Halite::ConnectionError do
        Halite.get("http://127.0.0.1:000")
      end
    end

    it "should throws a Halite::TimeoutError exception with long time not response" do
      expect_raises Halite::TimeoutError do
        Halite.timeout(read: 1.milliseconds).get("https://baidu.com/")
      end
    end

    it "should throws a Halite::RequestError exception with http request via tls" do
      expect_raises Halite::RequestError, "SSL context given for HTTP URI = http://google.com" do
        Halite.timeout(connect: 1.milliseconds).get("http://google.com", tls: OpenSSL::SSL::Context::Client.new)
      end
    end
  end

  describe Halite::FeatureRegister do
    it "should use a registered feature" do
      Halite.feature?("null").should be_nil
      Halite.register_feature "null", TestFeatures::Null
      Halite.has_feature?("null").should be_true
      Halite.feature("null").should eq(TestFeatures::Null)
    end
  end
end


================================================
FILE: spec/spec_helper.cr
================================================
require "spec"
require "./support/mock_server"
require "../src/halite"

def with_tempfile(filename)
  yield File.tempname(filename)
end

module TestFeatures
  class Null < Halite::Feature; end

  class AppendHeaders < Halite::Feature
    def request(request)
      request.headers["X-API-Limit"] = "60"
      request
    end

    def response(response)
      response.headers["X-Powered-By"] = "Halite"
      response
    end

    Halite.register_feature "append_headers", self
  end
end

module TestInterceptors
  class Mock < Halite::Feature
    def intercept(chain)
      response = Halite::Response.new(chain.request.uri, 400, "mock")
      chain.return(response)
    end

    Halite.register_feature "mock", self
  end

  class AlwaysNotFound < Halite::Feature
    def intercept(chain)
      response = chain.perform
      response = Halite::Response.new(chain.request.uri, 404, response.body, response.headers)
      chain.next(response)
    end

    Halite.register_feature "404", self
  end

  class PoweredBy < Halite::Feature
    def intercept(chain)
      if response = chain.response
        response.headers["X-Powered-By"] = "Halite"
        chain.return(response)
      else
        chain
      end
    end

    Halite.register_feature "powered_by", self
  end
end

class SimpleLogger < Halite::Logging::Abstract
  def request(request)
    @logger.info { "request" }
  end

  def response(response)
    @logger.info { "response" }
  end

  Halite::Logging.register "simple", self
end

def fixture_path(file)
  File.join(File.dirname(__FILE__), "fixtures", file)
end

def load_fixture(file)
  File.read_lines(fixture_path(file)).join("\n")
end

####################
# Start mock server
####################
SERVER = MockServer.new
spawn do
  SERVER.listen
end


================================================
FILE: spec/support/mock_server/route_handler.cr
================================================
class MockServer < HTTP::Server
  class RouteHandler
    include HTTP::Handler

    METHODS = [:get, :post, :put, :delete, :head, :patch, :options]
    ROUTES  = {} of String => (HTTP::Server::Context -> HTTP::Server::Context)

    def call(context : HTTP::Server::Context)
      process_route(context)
    end

    def process_route(context : HTTP::Server::Context)
      method = context.request.method.downcase
      path = context.request.path.to_s
      route = "#{method}:#{path}"

      if block = ROUTES[route]?
        block.call(context)
      else
        not_found(context)
      end
    end

    def not_found(context : HTTP::Server::Context)
      context.response.status_code = 404
      context.response.content_type = "text/html"
      context.response.print "Not Found"

      context
    end

    def self.not_found(context : HTTP::Server::Context)
      context.response.status_code = 404
      context.response.content_type = "text/html"
      context.response.print "Not Found"

      context
    end

    {% for verb in METHODS %}
      def self.{{ verb.id }}(route : String, &block : HTTP::Server::Context -> HTTP::Server::Context)
        ROUTES["{{ verb.id }}:#{route}"] = block
      end
    {% end %}

    def self.any(route : String, &block : HTTP::Server::Context -> HTTP::Server::Context)
      METHODS.each do |method|
        ROUTES["#{method}:#{route}"] = block
      end
    end

    # Any
    any "/anything" do |context|
      body = {
        "verb"    => context.request.method,
        "url"     => context.request.resource,
        "query"   => context.request.query,
        "headers" => context.request.headers.to_flat_h,
      }

      context.response.status_code = 200
      context.response.content_type = "application/json"
      context.response.print body.to_json
      context
    end

    any "/stream" do |context|
      total = context.request.query_params["n"].to_i

      body = {
        "verb"    => context.request.method,
        "url"     => context.request.resource,
        "query"   => context.request.query,
        "headers" => context.request.headers.to_flat_h,
      }

      total.times do |i|
        context.response.puts body.to_json
        context.response.flush
      end

      context
    end

    # GET
    get "/" do |context|
      context.response.status_code = 200

      case context.request.headers["Accept"]?
      when "application/json"
        context.response.content_type = "application/json"
        context.response.print "{\"json\": true}"
      else
        context.response.content_type = "text/html"
        context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      end

      context
    end

    get "/sleep" do |context|
      sleep 2

      context.response.status_code = 200
      context.response.print "hello"
      context
    end

    get "/params" do |context|
      next not_found(context) unless context.request.query == "foo=bar"

      context.response.status_code = 200
      context.response.print "Params!"
      context
    end

    get "/multiple-params" do |context|
      next not_found(context) unless context.request.query_params == HTTP::Params.new({"foo" => ["bar"], "baz" => ["quux"]})

      context.response.status_code = 200
      context.response.print "More Params!"
      context
    end

    get "/bytes" do |context|
      bytes = [80, 75, 3, 4, 20, 0, 0, 0, 8, 0, 123, 104, 169, 70, 99, 243, 243]
      context.response.content_type = "application/octet-stream"
      context.response.print bytes.map { |b| b.unsafe_chr }.join

      context
    end

    get "/image" do |context|
      path = File.expand_path("../../../../halite-logo.png", __FILE__)
      context.response.content_type = "image/png"
      context.response.content_length = File.size(path)
      context.response.headers["Content-Disposition"] = "attachment; filename=logo.png"
      File.open(path) do |file|
        IO.copy(file, context.response)
      end
      context
    end

    get "/redirect-301" do |context|
      context.response.status_code = 301
      location =
        if context.request.query_params["relative_path"]?
          "/"
        elsif context.request.query_params["relative_path_without_slash"]?
          "sleep"
        else
          {% if Crystal::VERSION < "0.36.0" %}
            "http://#{context.request.host_with_port}/"
          {% else %}
            "http://#{context.request.headers["Host"]?}/"
          {% end %}
        end

      context.response.headers["Location"] = location
      context
    end

    get "/redirect-302" do |context|
      context.response.status_code = 302
      location =
        if context.request.query_params["relative_path"]?
          "/"
        else
          {% if Crystal::VERSION < "0.36.0" %}
            "http://#{context.request.host_with_port}/"
          {% else %}
            "http://#{context.request.headers["Host"]?}/"
          {% end %}
        end

      context.response.headers["Location"] = location
      context
    end

    get "/multi-redirect" do |context|
      context.response.status_code = 302
      if n = context.request.query_params["n"]?
        n = n.to_i
        next_r = if (r = context.request.query_params["r"]?)
                   r.to_i + 1
                 else
                   1
                 end

        if next_r <= n
          location = "/multi-redirect?n=#{n}&r=#{next_r}"
          context.response.headers["Location"] = location
        else
          context.response.status_code = 200
          context.response.print "Finished #{n} redirect"
        end
      else
        context.response.status_code = 200
        context.response.print "Please Set ?n={n} to multi-redirect"
      end

      context
    end

    get "/cookies" do |context|
      context.response.headers["Set-Cookie"] = "foo=bar"
      context.response.print context.request.cookies.map { |c| "#{c.name}: #{c.value}" }.join("\n")

      context
    end

    get "/get-cookies" do |context|
      body = JSON.build do |json|
        json.object do
          context.request.cookies.each do |cookie|
            json.field cookie.name do
              cookie.value.to_json(json)
            end
          end
        end
      end

      context.response.content_type = "application/json"
      context.response.print body

      context
    end

    get "/user_agent" do |context|
      body = context.request.headers["User-Agent"]
      context.response.print body
      context
    end

    get "/auth" do |context|
      body = context.request.headers["Authorization"]
      context.response.print body
      context
    end

    get "/rate-limit" do |context|
      context.response.headers["X-RateLimit-Limit"] = "6000"
      context.response.headers["X-RateLimit-Remaining"] = "5998"
      context.response.headers["X-RateLimit-Reset"] = "1613727325"
      context
    end

    # POST
    post "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    post "/echo-body" do |context|
      body = parse_body(context.request.body)
      context.response.status_code = 200
      context.response.content_length = body.bytesize
      context.response.print body
      context
    end

    post "/form" do |context|
      form = parse_form(context.request.body)
      if form.empty?
        context.response.status_code = 400
        context.response.print "invalid form data! >:E"
      else
        context.response.status_code = 200
        form.each do |k, v|
          context.response.print "#{k}: #{v}\n"
        end
      end

      context
    end

    post "/upload" do |context|
      if multipart?(context.request.headers)
        upload = parse_upload_form(context.request)
        context.response.status_code = 200
        context.response.content_type = "application/json"

        body = JSON.build do |json|
          json.object do
            json.field "params" do
              json.object do
                upload.params.each do |k, v|
                  json.field k, v
                end
              end
            end

            json.field "files" do
              json.object do
                upload.files.each do |k, v|
                  json.field k do
                    if v.is_a?(Array)
                      json.array do
                        v.as(Array).each do |vv|
                          json.object do
                            json.field "filename", vv.filename
                            json.field "body", "[binary file]"
                          end
                        end
                      end
                    else
                      json.object do
                        json.field "filename", v.filename
                        json.field "body", "[binary file]"
                      end
                    end
                  end
                end
              end
            end
          end
        end

        context.response.print body
      else
        context.response.status_code = 400
        context.response.print "invalid form data! >:E"
      end

      context
    end

    post "/sleep" do |context|
      sleep 2

      context.response.status_code = 200
      context.response.print "hello"
      context
    end

    # PUT
    put "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    # DELETE
    delete "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    # HEAD
    head "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    # PATCH
    patch "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    # OPTIONS
    options "/" do |context|
      context.response.status_code = 200
      context.response.content_type = "text/html"
      context.response.print "<!doctype html><body>Mock Server is running.</body></html>"
      context
    end

    private def self.parse_body(body : (IO | String)?) : String
      case body
      when IO
        body.gets_to_end
      when String
        body
      else
        ""
      end
    end

    private def self.multipart?(headers : HTTP::Headers)
      if content_type = headers["content_type"]?
        return content_type.includes?("multipart/form-data") ? true : false
      end

      false
    end

    private def self.parse_form(body : (IO | String)?) : HTTP::Params
      HTTP::Params.parse(parse_body(body))
    end

    private def self.parse_upload_form(request : HTTP::Request) : UploadParams
      params = HTTP::Params.parse("")
      files = {} of String => HTTP::FormData::Part | Array(HTTP::FormData::Part)

      HTTP::FormData.parse(request) do |part|
        next unless part

        name = part.name
        if part.filename
          if files.has_key?(name) && files[name].is_a?(HTTP::FormData::Part)
            file = files.delete(name).as(HTTP::FormData::Part)
            files[name] = [file, part]
          else
            files[name] = part
          end
        else
          params.add name, part.body.gets_to_end
        end
      end

      UploadParams.new(params, files)
    end

    record UploadParams, params : HTTP::Params, files : Hash(String, HTTP::FormData::Part | Array(HTTP::FormData::Part))
  end
end


================================================
FILE: spec/support/mock_server.cr
================================================
require "http/server"
require "./mock_server/route_handler"

class MockServer < HTTP::Server
  HANDLERS = MockServer::RouteHandler.new

  BIND_ADDRESS = "127.0.0.1"
  BIND_PORT    = 18624

  getter running : Bool

  @@instance = MockServer.new

  def self.instance
    @@instance
  end

  def initialize
    super(HANDLERS)
    @running = false
  end

  def listen
    @running = true
    bind_tcp(BIND_ADDRESS, BIND_PORT)
    super
  end

  def running?
    @running == true
  end

  def endpoint
    "#{scheme}://#{BIND_ADDRESS}:#{BIND_PORT}"
  end

  def host
    BIND_ADDRESS
  end

  def scheme
    "http"
  end

  def api(path : String)
    URI.parse(endpoint).resolve(path).to_s
  end
end


================================================
FILE: src/halite/chainable.cr
================================================
require "base64"

module Halite
  module Chainable
    {% for verb in %w(get head) %}
      # {{ verb.id.capitalize }} a resource
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything", params: {
      #   first_name: "foo",
      #   last_name:  "bar"
      # })
      # ```
      def {{ verb.id }}(uri : String, *,
                        headers : (Hash(String, _) | NamedTuple)? = nil,
                        params : (Hash(String, _) | NamedTuple)? = nil,
                        raw : String? = nil,
                        tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
        request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls)
      end

      # {{ verb.id.capitalize }} a streaming resource
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response|
      #   puts response.status_code
      #   while line = response.body_io.gets
      #     puts line
      #   end
      # end
      # ```
      def {{ verb.id }}(uri : String, *,
                        headers : (Hash(String, _) | NamedTuple)? = nil,
                        params : (Hash(String, _) | NamedTuple)? = nil,
                        raw : String? = nil,
                        tls : OpenSSL::SSL::Context::Client? = nil,
                        &block : Halite::Response ->)
        request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls, &block)
      end
    {% end %}

    {% for verb in %w(put post patch delete options) %}
      # {{ verb.id.capitalize }} a resource
      #
      # ### Request with form data
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything", form: {
      #   first_name: "foo",
      #   last_name:  "bar"
      # })
      # ```
      #
      # ### Request with json data
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything", json: {
      #   first_name: "foo",
      #   last_name:  "bar"
      # })
      # ```
      #
      # ### Request with raw string
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything", raw: "name=Peter+Lee&address=%23123+Happy+Ave&Language=C%2B%2B")
      # ```
      def {{ verb.id }}(uri : String, *,
                        headers : (Hash(String, _) | NamedTuple)? = nil,
                        params : (Hash(String, _) | NamedTuple)? = nil,
                        form : (Hash(String, _) | NamedTuple)? = nil,
                        json : (Hash(String, _) | NamedTuple)? = nil,
                        raw : String? = nil,
                        tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
        request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls)
      end

      # {{ verb.id.capitalize }} a streaming resource
      #
      # ```
      # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response|
      #   puts response.status_code
      #   while line = response.body_io.gets
      #     puts line
      #   end
      # end
      # ```
      def {{ verb.id }}(uri : String, *,
                        headers : (Hash(String, _) | NamedTuple)? = nil,
                        params : (Hash(String, _) | NamedTuple)? = nil,
                        form : (Hash(String, _) | NamedTuple)? = nil,
                        json : (Hash(String, _) | NamedTuple)? = nil,
                        raw : String? = nil,
                        tls : OpenSSL::SSL::Context::Client? = nil,
                        &block : Halite::Response ->)
        request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls, &block)
      end
    {% end %}

    # Adds a endpoint to the request.
    #
    #
    # ```
    # Halite.endpoint("https://httpbin.org")
    #   .get("/get")
    # ```
    def endpoint(endpoint : String | URI) : Halite::Client
      branch(default_options.with_endpoint(endpoint))
    end

    # Make a request with the given Basic authorization header
    #
    # ```
    # Halite.basic_auth("icyleaf", "p@ssw0rd")
    #   .get("http://httpbin.org/get")
    # ```
    #
    # See Also: [http://tools.ietf.org/html/rfc2617](http://tools.ietf.org/html/rfc2617)
    def basic_auth(user : String, pass : String) : Halite::Client
      auth("Basic " + Base64.strict_encode(user + ":" + pass))
    end

    # Make a request with the given Authorization header
    #
    # ```
    # Halite.auth("private-token", "6abaef100b77808ceb7fe26a3bcff1d0")
    #   .get("http://httpbin.org/get")
    # ```
    def auth(value : String) : Halite::Client
      headers({"Authorization" => value})
    end

    # Accept the given MIME type
    #
    # ```
    # Halite.accept("application/json")
    #   .get("http://httpbin.org/get")
    # ```
    def accept(value : String) : Halite::Client
      headers({"Accept" => value})
    end

    # Set requests user agent
    #
    # ```
    # Halite.user_agent("Custom User Agent")
    #   .get("http://httpbin.org/get")
    # ```
    def user_agent(value : String) : Halite::Client
      headers({"User-Agent" => value})
    end

    # Make a request with the given headers
    #
    # ```
    # Halite.headers({"Content-Type", "application/json", "Connection": "keep-alive"})
    #       .get("http://httpbin.org/get")
    # # Or
    # Halite.headers({content_type: "application/json", connection: "keep-alive"})
    #       .get("http://httpbin.org/get")
    # ```
    def headers(headers : Hash(String, _) | NamedTuple) : Halite::Client
      branch(default_options.with_headers(headers))
    end

    # Make a request with the given headers
    #
    # ```
    # Halite.headers(content_type: "application/json", connection: "keep-alive")
    #   .get("http://httpbin.org/get")
    # ```
    def headers(**kargs) : Halite::Client
      branch(default_options.with_headers(kargs))
    end

    # Make a request with the given cookies
    #
    # ```
    # Halite.cookies({"private-token", "6abaef100b77808ceb7fe26a3bcff1d0"})
    #       .get("http://httpbin.org/get")
    # # Or
    # Halite.cookies({private-token: "6abaef100b77808ceb7fe26a3bcff1d0"})
    #       .get("http://httpbin.org/get")
    # ```
    def cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Client
      branch(default_options.with_cookies(cookies))
    end

    # Make a request with the given cookies
    #
    # ```
    # Halite.cookies(name: "icyleaf", "gender": "male")
    #   .get("http://httpbin.org/get")
    # ```
    def cookies(**kargs) : Halite::Client
      branch(default_options.with_cookies(kargs))
    end

    # Make a request with the given cookies
    #
    # ```
    # cookies = HTTP::Cookies.from_client_headers(headers)
    # Halite.cookies(cookies)
    #   .get("http://httpbin.org/get")
    # ```
    def cookies(cookies : HTTP::Cookies) : Halite::Client
      branch(default_options.with_cookies(cookies))
    end

    # Adds a timeout to the request.
    #
    # How long to wait for the server to send data before giving up, as a int, float or time span.
    # The timeout value will be applied to both the connect and the read timeouts.
    #
    # Set `nil` to timeout to ignore timeout.
    #
    # ```
    # Halite.timeout(5.5).get("http://httpbin.org/get")
    # # Or
    # Halite.timeout(2.minutes)
    #   .post("http://httpbin.org/post", form: {file: "file.txt"})
    # ```
    def timeout(timeout : (Int32 | Float64 | Time::Span)?)
      timeout ? timeout(timeout, timeout, timeout) : branch
    end

    # Adds a timeout to the request.
    #
    # How long to wait for the server to send data before giving up, as a int, float or time span.
    # The timeout value will be applied to both the connect and the read timeouts.
    #
    # ```
    # Halite.timeout(3, 3.minutes, 5)
    #   .post("http://httpbin.org/post", form: {file: "file.txt"})
    # # Or
    # Halite.timeout(3.04, 64, 10.0)
    #   .get("http://httpbin.org/get")
    # ```
    def timeout(connect : (Int32 | Float64 | Time::Span)? = nil,
                read : (Int32 | Float64 | Time::Span)? = nil,
                write : (Int32 | Float64 | Time::Span)? = nil)
      branch(default_options.with_timeout(connect, read, write))
    end

    # Returns `Options` self with automatically following redirects.
    #
    # ```
    # # Automatically following redirects.
    # Halite.follow
    #   .get("http://httpbin.org/relative-redirect/5")
    #
    # # Always redirect with any request methods
    # Halite.follow(strict: false)
    #   .get("http://httpbin.org/get")
    # ```
    def follow(strict = Halite::Options::Follow::STRICT) : Halite::Client
      branch(default_options.with_follow(strict: strict))
    end

    # Returns `Options` self with given max hops of redirect times.
    #
    # ```
    # # Max hops 3 times
    # Halite.follow(3)
    #   .get("http://httpbin.org/relative-redirect/3")
    #
    # # Always redirect with any request methods
    # Halite.follow(4, strict: false)
    #   .get("http://httpbin.org/relative-redirect/4")
    # ```
    def follow(hops : Int32, strict = Halite::Options::Follow::STRICT) : Halite::Client
      branch(default_options.with_follow(hops, strict))
    end

    # Returns `Options` self with enable or disable logging.
    #
    # #### Enable logging
    #
    # Same as call `logging` method without any argument.
    #
    # ```
    # Halite.logging.get("http://httpbin.org/get")
    # ```
    #
    # #### Disable logging
    #
    # ```
    # Halite.logging(false).get("http://httpbin.org/get")
    # ```
    def logging(enable : Bool = true)
      options = default_options
      options.logging = enable
      branch(options)
    end

    # Returns `Options` self with given the logging which it integration from `Halite::Logging`.
    #
    # #### Simple logging
    #
    # ```
    # Halite.logging
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    #
    # => 2018-08-28 14:33:19 +08:00 | request  | POST   | http://httpbin.org/post
    # => 2018-08-28 14:33:21 +08:00 | response | 200    | http://httpbin.org/post | 1.61s | application/json
    # { ... }
    # ```
    #
    # #### Logger configuration
    #
    # By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level.
    # You can configuring the following options:
    #
    # - `skip_request_body`: By default is `false`.
    # - `skip_response_body`: By default is `false`.
    # - `skip_benchmark`: Display elapsed time, by default is `false`.
    # - `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`.
    #
    # ```
    # Halite.logging(skip_request_body: true, skip_response_body: true)
    #   .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")})
    #
    # # => 2018-08-28 14:33:19 +08:00 | request  | POST   | http://httpbin.org/post
    # # => 2018-08-28 14:33:21 +08:00 | response | 200    | http://httpbin.org/post | 1.61s | application/json
    # ```
    #
    # #### Use custom logging
    #
    # Creating the custom logging by integration `Halite::Logging::Abstract` abstract class.
    # Here has two methods must be implement: `#request` and `#response`.
    #
    # ```
    # class CustomLogger < Halite::Logging::Abstract
    #   def request(request)
    #     @logger.info "| >> | %s | %s %s" % [request.verb, request.uri, request.body]
    #   end
    #
    #   def response(response)
    #     @logger.info "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type]
    #   end
    # end
    #
    # # Add to adapter list (optional)
    # Halite::Logging.register_adapter "custom", CustomLogger.new
    #
    # Halite.logging(logging: CustomLogger.new)
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    #
    # # We can also call it use format name if you added it.
    # Halite.logging(format: "custom")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    #
    # # => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar
    # # => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json
    # ```
    def logging(logging : Halite::Logging::Abstract = Halite::Logging::Common.new)
      branch(default_options.with_logging(logging))
    end

    # Returns `Options` self with given the file with the path.
    #
    # #### JSON-formatted logging
    #
    # ```
    # Halite.logging(format: "json")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    # ```
    #
    # #### create a http request and log to file
    #
    # ```
    # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a")))
    # Halite.logging(for: "halite.file")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    # ```
    #
    # #### Always create new log file and store data to JSON formatted
    #
    # ```
    # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w"))
    # Halite.logging(for: "halite.file", format: "json")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    # ```
    #
    # Check the log file content: **/tmp/halite.log**
    def logging(format : String = "common", *, for : String = "halite",
                skip_request_body = false, skip_response_body = false,
                skip_benchmark = false, colorize = true)
      opts = {
        for:                for,
        skip_request_body:  skip_request_body,
        skip_response_body: skip_response_body,
        skip_benchmark:     skip_benchmark,
        colorize:           colorize,
      }
      branch(default_options.with_logging(format, **opts))
    end

    # Turn on given features and its options.
    #
    # Available features to review all subclasses of `Halite::Feature`.
    #
    # #### Use JSON logging
    #
    # ```
    # Halite.use("logging", format: "json")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    #
    # # => { ... }
    # ```
    #
    # #### Use common format logging and skip response body
    # ```
    # Halite.use("logging", format: "common", skip_response_body: true)
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    #
    # # => 2018-08-28 14:58:26 +08:00 | request  | GET    | http://httpbin.org/get
    # # => 2018-08-28 14:58:27 +08:00 | response | 200    | http://httpbin.org/get | 615.8ms | application/json
    # ```
    def use(feature : String, **opts)
      branch(default_options.with_features(feature, **opts))
    end

    # Turn on given the name of features.
    #
    # Available features to review all subclasses of `Halite::Feature`.
    #
    # ```
    # Halite.use("logging", "your-custom-feature-name")
    #   .get("http://httpbin.org/get", params: {name: "foobar"})
    # ```
    def use(*features)
      branch(default_options.with_features(*features))
    end

    # Make an HTTP request with the given verb
    #
    # ```
    # Halite.request("get", "http://httpbin.org/get", {
    #   "headers" = { "user_agent" => "halite" },
    #   "params" => { "nickname" => "foo" },
    #   "form" => { "username" => "bar" },
    # })
    # ```
    def request(verb : String, uri : String, *,
                headers : (Hash(String, _) | NamedTuple)? = nil,
                params : (Hash(String, _) | NamedTuple)? = nil,
                form : (Hash(String, _) | NamedTuple)? = nil,
                json : (Hash(String, _) | NamedTuple)? = nil,
                raw : String? = nil,
                tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
      request(verb, uri, options_with(headers, params, form, json, raw, tls))
    end

    # Make an HTTP request with the given verb and options
    #
    # > This method will be executed with oneshot request.
    #
    # ```
    # Halite.request("get", "http://httpbin.org/stream/3", headers: {"user-agent" => "halite"}) do |response|
    #   puts response.status_code
    #   while line = response.body_io.gets
    #     puts line
    #   end
    # end
    # ```
    def request(verb : String, uri : String, *,
                headers : (Hash(String, _) | NamedTuple)? = nil,
                params : (Hash(String, _) | NamedTuple)? = nil,
                form : (Hash(String, _) | NamedTuple)? = nil,
                json : (Hash(String, _) | NamedTuple)? = nil,
                raw : String? = nil,
                tls : OpenSSL::SSL::Context::Client? = nil,
                &block : Halite::Response ->)
      request(verb, uri, options_with(headers, params, form, json, raw, tls), &block)
    end

    # Make an HTTP request with the given verb and options
    #
    # > This method will be executed with oneshot request.
    #
    # ```
    # Halite.request("get", "http://httpbin.org/get", Halite::Options.new(
    #   "headers" = { "user_agent" => "halite" },
    #   "params" => { "nickname" => "foo" },
    #   "form" => { "username" => "bar" },
    # )
    # ```
    def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response
      branch(options).request(verb, uri)
    end

    # Make an HTTP request with the given verb and options
    #
    # > This method will be executed with oneshot request.
    #
    # ```
    # Halite.request("get", "http://httpbin.org/stream/3") do |response|
    #   puts response.status_code
    #   while line = response.body_io.gets
    #     puts line
    #   end
    # end
    # ```
    def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->)
      branch(options).request(verb, uri, &block)
    end

    private def branch(options : Halite::Options? = nil) : Halite::Client
      options ||= default_options
      Halite::Client.new(options)
    end

    private DEFAULT_OPTIONS = Halite::Options.new

    private def default_options
      {% if @type.superclass %}
        @default_options
      {% else %}
        DEFAULT_OPTIONS.clear!
      {% end %}
    end

    private def options_with(headers : (Hash(String, _) | NamedTuple)? = nil,
                             params : (Hash(String, _) | NamedTuple)? = nil,
                             form : (Hash(String, _) | NamedTuple)? = nil,
                             json : (Hash(String, _) | NamedTuple)? = nil,
                             raw : String? = nil,
                             tls : OpenSSL::SSL::Context::Client? = nil)
      options = Halite::Options.new(headers: headers, params: params, form: form, json: json, raw: raw, tls: tls)
      default_options.merge!(options)
    end
  end
end


================================================
FILE: src/halite/client.cr
================================================
require "./request"
require "./response"
require "./redirector"

require "http/client"
require "json"

module Halite
  # Clients make requests and receive responses
  #
  # Support all `Chainable` methods.
  #
  # ### Simple setup
  #
  # ```
  # client = Halite::Client.new(headers: {
  #   "private-token" => "bdf39d82661358f80b31b67e6f89fee4"
  # })
  #
  # client.auth(private_token: "bdf39d82661358f80b31b67e6f89fee4").
  #       .get("http://httpbin.org/get", params: {
  #         name: "icyleaf"
  #       })
  # ```
  #
  # ### Setup with block
  #
  # ```
  # client = Halite::Client.new do
  #   basic_auth "name", "foo"
  #   headers content_type: "application/jsong"
  #   read_timeout 3.minutes
  #   logging true
  # end
  # ```
  class Client
    include Chainable

    # Instance a new client
    #
    # ```
    # Halite::Client.new(headers: {"private-token" => "bdf39d82661358f80b31b67e6f89fee4"})
    # ```
    def self.new(*,
                 endpoint : (String | URI)? = nil,
                 headers : (Hash(String, _) | NamedTuple)? = nil,
                 cookies : (Hash(String, _) | NamedTuple)? = nil,
                 params : (Hash(String, _) | NamedTuple)? = nil,
                 form : (Hash(String, _) | NamedTuple)? = nil,
                 json : (Hash(String, _) | NamedTuple)? = nil,
                 raw : String? = nil,
                 timeout = Timeout.new,
                 follow = Follow.new,
                 tls : OpenSSL::SSL::Context::Client? = nil)
      new(Options.new(
        endpoint: endpoint,
        headers: headers,
        cookies: cookies,
        params: params,
        form: form,
        json: json,
        raw: raw,
        tls: tls,
        timeout: timeout,
        follow: follow
      ))
    end

    property options

    # Instance a new client with block
    #
    # ```
    # client = Halite::Client.new do
    #   basic_auth "name", "foo"
    #   logging true
    # end
    # ```
    def self.new(&block)
      instance = new
      yield_instance = with instance yield
      if yield_instance
        # yield_instance.options.merge!(yield_instance.oneshot_options)
        # yield_instance.oneshot_options.clear!
        instance = yield_instance
      end

      instance
    end

    # Instance a new client
    #
    # ```
    # options = Halite::Options.new(headers: {
    #   "private-token" => "bdf39d82661358f80b31b67e6f89fee4",
    # })
    #
    # client = Halite::Client.new(options)
    # ```
    def initialize(@options = Halite::Options.new)
      @history = [] of Response
      @default_options = Halite::Options.new(@options)
    end

    # Make an HTTP request
    def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response
      opts = options ? @default_options.merge(options.not_nil!) : @default_options
      request = build_request(verb, uri, opts)
      response = perform_chain(request, opts) do
        perform(request, opts)
      end

      return response if opts.follow.hops.zero?

      Redirector.new(request, response, opts).perform do |req|
        perform(req, opts)
      end
    end

    # Make an HTTP request
    def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->)
      opts = options ? @default_options.merge(options.not_nil!) : @default_options
      request = build_request(verb, uri, opts)
      perform(request, opts, &block)
    end

    # Find interceptor and return `Response` else perform HTTP request.
    private def perform_chain(request : Halite::Request, options : Halite::Options, &block : -> Response)
      chain = Feature::Chain.new(request, nil, options, &block)
      options.features.each do |_, feature|
        current_chain = feature.intercept(chain)
        if current_chain.result == Feature::Chain::Result::Next
          chain = current_chain
        elsif current_chain.result == Feature::Chain::Result::Return && (response = current_chain.response)
          return handle_response(response, options)
        end
      end

      # Make sure return if has response with each interceptor
      if response = chain.response
        return handle_response(response, options)
      end

      # Perform original HTTP request if not found any response in interceptors
      block.call
    end

    # Perform a single (no follow) HTTP request
    private def perform(request : Halite::Request, options : Halite::Options) : Halite::Response
      raise RequestError.new("SSL context given for HTTP URI = #{request.uri}") if request.scheme == "http" && options.tls

      conn = make_connection(request, options)
      conn_response = conn.exec(request.verb, request.full_path, request.headers, request.body)
      handle_response(request, conn_response, options)
    rescue ex : IO::TimeoutError
      raise TimeoutError.new(ex.message)
    rescue ex : Socket::Error
      raise ConnectionError.new(ex.message)
    end

    # Perform a single (no follow) streaming HTTP request and redirect automatically
    private def perform(request : Halite::Request, options : Halite::Options, &block : Halite::Response ->)
      raise RequestError.new("SSL context given for HTTP URI = #{request.uri}") if request.scheme == "http" && options.tls

      conn = make_connection(request, options)
      conn.exec(request.verb, request.full_path, request.headers, request.body) do |conn_response|
        response = handle_response(request, conn_response, options)
        redirector = Redirector.new(request, response, options)
        if redirector.avaiable?
          redirector.each_redirect do |req|
            perform(req, options, &block)
          end
        else
          block.call(response)
        end

        return response
      end
    end

    # Prepare a HTTP request
    private def build_request(verb : String, uri : String, options : Halite::Options) : Halite::Request
      uri = make_request_uri(uri, options)
      body_data = make_request_body(options)
      headers = make_request_headers(options, body_data.content_type)
      request = Request.new(verb, uri, headers, body_data.body)

      # reset options during onshot request, see `default_options` method at the bottom of file.
      # default_options.clear!

      options.features.reduce(request) do |req, (_, feature)|
        feature.request(req)
      end
    end

    # Merges query params if needed
    private def make_request_uri(url : String, options : Halite::Options) : URI
      uri = resolve_uri(url, options)
      if params = options.params
        query = HTTP::Params.encode(params)
        uri.query = [uri.query, query].compact.join('&') unless query.empty?
      end

      uri
    end

    # Merges request headers
    private def make_request_headers(options : Halite::Options, content_type : String?) : HTTP::Headers
      headers = options.headers
      if (value = content_type) && !value.empty? && !headers.has_key?("Content-Type")
        headers.add("Content-Type", value)
      end

      # Cookie shards
      options.cookies.add_request_headers(headers)
    end

    # Create the request body object to send
    private def make_request_body(options : Halite::Options) : Halite::Request::Data
      if (form = options.form) && !form.empty?
        FormData.create(form)
      elsif (hash = options.json) && !hash.empty?
        body = JSON.build do |builder|
          hash.to_json(builder)
        end

        Halite::Request::Data.new(body, "application/json")
      elsif (raw = options.raw) && !raw.empty?
        Halite::Request::Data.new(raw, "text/plain")
      else
        Halite::Request::Data.new("")
      end
    end

    # Create the http connection
    private def make_connection(request, options)
      conn = HTTP::Client.new(request.domain, options.tls)
      conn.connect_timeout = options.timeout.connect.not_nil! if options.timeout.connect
      conn.read_timeout = options.timeout.read.not_nil! if options.timeout.read
      conn.write_timeout = options.timeout.write.not_nil! if options.timeout.write
      conn
    end

    # Convert HTTP::Client::Response to response and handles response (see below)
    private def handle_response(request, conn_response : HTTP::Client::Response, options) : Halite::Response
      response = Response.new(uri: request.uri, conn: conn_response, history: @history)
      handle_response(response, options)
    end

    # Handles response by reduce the response of feature, add history and update options
    private def handle_response(response, options) : Halite::Response
      response = options.features.reduce(response) do |res, (_, feature)|
        feature.response(res)
      end

      # Append history of response if enable follow
      @history << response unless options.follow.hops.zero?
      store_cookies_from_response(response)
    end

    # Store cookies for sessions use from response
    private def store_cookies_from_response(response : Halite::Response) : Halite::Response
      return response unless response.headers

      @default_options.with_cookies(HTTP::Cookies.from_server_headers(response.headers))
      response
    end

    # # Use in instance/session mode, it will replace same method in `Halite::Chainable`.
    # private def branch(options : Halite::Options? = nil) : Halite::Client
    #   oneshot_options.merge!(options)
    #   self
    # end

    private def resolve_uri(url : String, options : Halite::Options) : URI
      return URI.parse(url) unless endpoint = options.endpoint
      return endpoint if url.empty?

      endpoint.path += '/' unless endpoint.path.ends_with?('/')
      endpoint.resolve(url)
    end
  end
end


================================================
FILE: src/halite/error.cr
================================================
module Halite
  module Exception
    # Generic error
    class Error < ::Exception; end

    # Generic Connection error
    class ConnectionError < Error; end

    # Generic Request error
    class RequestError < Error; end

    # Generic Response error
    class ResponseError < Error; end

    # Generic Feature error
    class FeatureError < Error; end

    # The method given was not understood
    class UnsupportedMethodError < RequestError; end

    # The scheme given was not understood
    class UnsupportedSchemeError < RequestError; end

    # The head method can not streaming without empty response
    class UnsupportedStreamMethodError < RequestError; end

    # Requested to do something when we're in the wrong state
    class StateError < RequestError; end

    # Generic Timeout error
    class TimeoutError < RequestError; end

    # The feature given was not understood
    class UnRegisterFeatureError < FeatureError; end

    # The format given was not understood
    class UnRegisterLoggerFormatError < FeatureError; end

    # Notifies that we reached max allowed redirect hops
    class TooManyRedirectsError < ResponseError; end

    # Notifies that following redirects got into an endless loop
    class EndlessRedirectError < TooManyRedirectsError; end

    # The MIME type(adapter) given was not understood
    class UnRegisterMimeTypeError < ResponseError; end

    # Generic API error
    class APIError < ResponseError
      getter uri
      getter status_code
      getter status_message : String? = nil

      def initialize(@message : String? = nil, @status_code : Int32? = nil, @uri : URI? = nil)
        @status_message = build_status_message
        if status_code = @status_code
          @message ||= "#{status_code} #{@status_message}"
        end

        super(@message)
      end

      private def build_status_message : String
        String::Builder.build do |io|
          if status_code = @status_code
            io << "#{HTTP::Status.new(status_code).description.to_s.downcase} error"
          else
            io << "#{@message || "unknown"} error"
          end

          io << " with url: #{@uri}" if uri = @uri
        end.to_s
      end
    end

    # 4XX client error
    class ClientError < APIError; end

    # 5XX server error
    class ServerError < APIError; end
  end

  {% for cls in Exception.constants %}
    # :nodoc:
    alias {{ cls.id }} = Exception::{{ cls.id }}
  {% end %}
end


================================================
FILE: src/halite/ext/file_to_json.cr
================================================
# :nodoc:
class File
  def to_json(json : JSON::Builder)
    json.string(to_s)
  end
end


================================================
FILE: src/halite/ext/http_headers_encode.cr
================================================
module HTTP
  # This is **extension** apply in Halite.
  struct Headers
    # Returns the given key value pairs as HTTP Headers
    #
    # Every parameter added is directly written to an IO, where keys are properly escaped.
    #
    # ```
    # HTTP::Headers.encode({
    #   content_type: "application/json",
    # })
    # # => "HTTP::Headers{"Content-Type" => "application/json"}"
    #
    # HTTP::Headers.encode({
    #   "conTENT-type": "application/json",
    # })
    # # => "HTTP::Headers{"Content-Type" => "application/json"}"
    # ```
    def self.encode(data : Hash(String, _) | NamedTuple) : HTTP::Headers
      ::HTTP::Headers.new.tap do |builder|
        data = data.is_a?(NamedTuple) ? data.to_h : data
        data.each do |key, value|
          key = key.to_s.gsub("_", "-").split("-").map { |v| v.capitalize }.join("-")
          # skip invalid value of content length
          next if key == "Content-Length" && !(value =~ /^\d+$/)

          builder.add key, value.is_a?(Array(String)) ? value : value.to_s
        end
      end
    end

    # Same as `#encode`
    def self.encode(**data)
      encode(data)
    end

    # Similar as `Hahs#to_h` but return `String` if it has one value of the key.
    #
    # ```
    # headers = HTTP::Headers{"Accepts" => ["application/json", "text/html"], "Content-Type" => ["text/html"]}
    # headers[
Download .txt
gitextract_d07hks70/

├── .editorconfig
├── .github/
│   ├── scripts/
│   │   └── generate_docs.sh
│   └── workflows/
│       ├── api-document.yml
│       ├── linux-ci.yml
│       └── release-version.yml
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── shard.yml
├── spec/
│   ├── fixtures/
│   │   └── cache_file.json
│   ├── halite/
│   │   ├── client_spec.cr
│   │   ├── error_spec.cr
│   │   ├── ext/
│   │   │   ├── http_headers_encode_spec.cr
│   │   │   └── http_params_encode_spec.cr
│   │   ├── feature_spec.cr
│   │   ├── features/
│   │   │   ├── cache_spec.cr
│   │   │   └── logging_spec.cr
│   │   ├── header_link_spec.cr
│   │   ├── mime_type_spec.cr
│   │   ├── mime_types/
│   │   │   └── json_spec.cr
│   │   ├── options/
│   │   │   ├── follow_spec.cr
│   │   │   └── timeout_spec.cr
│   │   ├── options_spec.cr
│   │   ├── rate_limit_spec.cr
│   │   ├── redirector_spec.cr
│   │   ├── request_spec.cr
│   │   └── response_spec.cr
│   ├── halite_spec.cr
│   ├── spec_helper.cr
│   └── support/
│       ├── mock_server/
│       │   └── route_handler.cr
│       └── mock_server.cr
└── src/
    ├── halite/
    │   ├── chainable.cr
    │   ├── client.cr
    │   ├── error.cr
    │   ├── ext/
    │   │   ├── file_to_json.cr
    │   │   ├── http_headers_encode.cr
    │   │   └── http_params_encode.cr
    │   ├── feature.cr
    │   ├── features/
    │   │   ├── cache.cr
    │   │   ├── logging/
    │   │   │   ├── common.cr
    │   │   │   └── json.cr
    │   │   └── logging.cr
    │   ├── form_data.cr
    │   ├── header_link.cr
    │   ├── mime_type.cr
    │   ├── mime_types/
    │   │   └── json.cr
    │   ├── options/
    │   │   ├── follow.cr
    │   │   └── timeout.cr
    │   ├── options.cr
    │   ├── rate_limit.cr
    │   ├── redirector.cr
    │   ├── request.cr
    │   └── response.cr
    └── halite.cr
Condensed preview — 56 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (264K chars).
[
  {
    "path": ".editorconfig",
    "chars": 137,
    "preview": "[*.cr]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_w"
  },
  {
    "path": ".github/scripts/generate_docs.sh",
    "chars": 2074,
    "preview": "#!/usr/bin/env sh\n\nDOCS_PATH=\"docs\"\nTAGS=$(git tag -l)\nDEFAULT_VERSION=$(git tag -l | sort -V | tail -n 1)\nDEFAULT_VERSI"
  },
  {
    "path": ".github/workflows/api-document.yml",
    "chars": 762,
    "preview": "name: Deploy API documents\non:\n  push:\n    paths-ignore:\n      - \"benchmarks/**\"\n    branches:\n      - \"master\"\n    tags"
  },
  {
    "path": ".github/workflows/linux-ci.yml",
    "chars": 771,
    "preview": "name: Linux CI\non:\n  push:\n    paths-ignore:\n      - \"benchmarks/**\"\n    branches:\n      - \"master\"\n  pull_request:\n    "
  },
  {
    "path": ".github/workflows/release-version.yml",
    "chars": 446,
    "preview": "name: Deploy new release\non:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n    -"
  },
  {
    "path": ".gitignore",
    "chars": 186,
    "preview": "docs/\nlib/\nbin/\nlogs/\n.shards/\n\n# Local test file\nmain.cr\n\n# Libraries don't need dependency lock\n# Dependencies will be"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 15407,
    "preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 3217,
    "preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
  },
  {
    "path": "LICENSE",
    "chars": 1082,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2017-present icyleaf\n\nPermission is hereby granted, free of charge, to any person o"
  },
  {
    "path": "README.md",
    "chars": 29181,
    "preview": "![halite-logo](https://github.com/icyleaf/halite/raw/master/halite-logo-small.png)\n\n# Halite\n\n[![Language](https://img.s"
  },
  {
    "path": "shard.yml",
    "chars": 119,
    "preview": "name: halite\nversion: 0.12.1\n\nauthors:\n  - icyleaf <icyleaf.cn@gmail.com>\n\ncrystal: \">= 0.36.1, < 2.0.0\"\n\nlicense: MIT\n"
  },
  {
    "path": "spec/fixtures/cache_file.json",
    "chars": 16,
    "preview": "{\"name\":\"foo3\"}\n"
  },
  {
    "path": "spec/halite/client_spec.cr",
    "chars": 5317,
    "preview": "require \"../spec_helper\"\nrequire \"../support/mock_server\"\n\ndescribe Halite::Client do\n  # It accepts all chainable metho"
  },
  {
    "path": "spec/halite/error_spec.cr",
    "chars": 1839,
    "preview": "require \"../spec_helper\"\n\ndescribe Halite::Exception do\n  describe \"#APIError\" do\n    it \"should initial without argumen"
  },
  {
    "path": "spec/halite/ext/http_headers_encode_spec.cr",
    "chars": 1200,
    "preview": "require \"../../spec_helper\"\n\ndescribe HTTP::Headers do\n  describe \"#encode\" do\n    it \"should accepts Hash(String, _)\" d"
  },
  {
    "path": "spec/halite/ext/http_params_encode_spec.cr",
    "chars": 1474,
    "preview": "require \"../../spec_helper\"\n\ndescribe HTTP::Params do\n  describe \"#encode\" do\n    it \"should encode hash to url-encoded "
  },
  {
    "path": "spec/halite/feature_spec.cr",
    "chars": 287,
    "preview": "require \"../spec_helper\"\n\ndescribe Halite::Feature do\n  it \"should a empty feature\" do\n    feature = TestFeatures::Null."
  },
  {
    "path": "spec/halite/features/cache_spec.cr",
    "chars": 8573,
    "preview": "require \"../../spec_helper\"\n\nprivate struct CacheStruct\n  getter metadata, body, chain\n\n  def initialize(@body : String,"
  },
  {
    "path": "spec/halite/features/logging_spec.cr",
    "chars": 2055,
    "preview": "require \"../../spec_helper\"\n\nprivate class NulleLogger < Halite::Logging::Abstract\n  def request(request)\n  end\n\n  def r"
  },
  {
    "path": "spec/halite/header_link_spec.cr",
    "chars": 3274,
    "preview": "require \"../spec_helper\"\n\nprivate def parse_it(raw : String, uri : URI? = nil)\n  Halite::HeaderLink.parse(raw, uri)\nend\n"
  },
  {
    "path": "spec/halite/mime_type_spec.cr",
    "chars": 543,
    "preview": "require \"../spec_helper\"\nrequire \"yaml\"\n\nprivate class YAMLAdapter < Halite::MimeType::Adapter\n  def decode(string)\n    "
  },
  {
    "path": "spec/halite/mime_types/json_spec.cr",
    "chars": 525,
    "preview": "require \"../../spec_helper\"\n\nprivate class Foo\nend\n\ndescribe Halite::MimeType::JSON do\n  describe \"#encode\" do\n    it \"s"
  },
  {
    "path": "spec/halite/options/follow_spec.cr",
    "chars": 716,
    "preview": "require \"../../spec_helper\"\n\ndescribe Halite::Follow do\n  describe \"#initialize\" do\n    it \"should work\" do\n      follow"
  },
  {
    "path": "spec/halite/options/timeout_spec.cr",
    "chars": 2591,
    "preview": "require \"../../spec_helper\"\n\ndescribe Halite::Timeout do\n  describe \"#initialize\" do\n    it \"should set with Int32\" do\n "
  },
  {
    "path": "spec/halite/options_spec.cr",
    "chars": 22004,
    "preview": "require \"../spec_helper\"\n\nprivate class SimpleFeature < Halite::Feature\n  def request(request)\n    request\n  end\n\n  def "
  },
  {
    "path": "spec/halite/rate_limit_spec.cr",
    "chars": 1665,
    "preview": "require \"../spec_helper\"\n\n# private def parse_it(raw : String, uri : URI? = nil)\n#   Halite::HeaderLinkParser.parse(raw,"
  },
  {
    "path": "spec/halite/redirector_spec.cr",
    "chars": 7566,
    "preview": "require \"../spec_helper\"\n\nprivate def request\n  Halite::Request.new(\"head\", URI.parse(\"http://example.com/foo?bar=baz\"))"
  },
  {
    "path": "spec/halite/request_spec.cr",
    "chars": 3338,
    "preview": "require \"../spec_helper\"\n\nprivate def request\n  Halite::Request.new(\n    \"get\",\n    URI.parse(\"http://example.com/foo/ba"
  },
  {
    "path": "spec/halite/response_spec.cr",
    "chars": 6243,
    "preview": "require \"../spec_helper\"\n\nprivate URL         = \"http://example.com\"\nprivate STATUS_CODE = 200\nprivate HEADERS     = HTT"
  },
  {
    "path": "spec/halite_spec.cr",
    "chars": 25631,
    "preview": "require \"./spec_helper\"\n\nprivate def without_timezone(&block)\n  with_timezone(nil, &block)\nend\n\nprivate def with_timezon"
  },
  {
    "path": "spec/spec_helper.cr",
    "chars": 1774,
    "preview": "require \"spec\"\nrequire \"./support/mock_server\"\nrequire \"../src/halite\"\n\ndef with_tempfile(filename)\n  yield File.tempnam"
  },
  {
    "path": "spec/support/mock_server/route_handler.cr",
    "chars": 11992,
    "preview": "class MockServer < HTTP::Server\n  class RouteHandler\n    include HTTP::Handler\n\n    METHODS = [:get, :post, :put, :delet"
  },
  {
    "path": "spec/support/mock_server.cr",
    "chars": 696,
    "preview": "require \"http/server\"\nrequire \"./mock_server/route_handler\"\n\nclass MockServer < HTTP::Server\n  HANDLERS = MockServer::Ro"
  },
  {
    "path": "src/halite/chainable.cr",
    "chars": 18652,
    "preview": "require \"base64\"\n\nmodule Halite\n  module Chainable\n    {% for verb in %w(get head) %}\n      # {{ verb.id.capitalize }} a"
  },
  {
    "path": "src/halite/client.cr",
    "chars": 9662,
    "preview": "require \"./request\"\nrequire \"./response\"\nrequire \"./redirector\"\n\nrequire \"http/client\"\nrequire \"json\"\n\nmodule Halite\n  #"
  },
  {
    "path": "src/halite/error.cr",
    "chars": 2453,
    "preview": "module Halite\n  module Exception\n    # Generic error\n    class Error < ::Exception; end\n\n    # Generic Connection error\n"
  },
  {
    "path": "src/halite/ext/file_to_json.cr",
    "chars": 89,
    "preview": "# :nodoc:\nclass File\n  def to_json(json : JSON::Builder)\n    json.string(to_s)\n  end\nend\n"
  },
  {
    "path": "src/halite/ext/http_headers_encode.cr",
    "chars": 2026,
    "preview": "module HTTP\n  # This is **extension** apply in Halite.\n  struct Headers\n    # Returns the given key value pairs as HTTP "
  },
  {
    "path": "src/halite/ext/http_params_encode.cr",
    "chars": 2479,
    "preview": "module HTTP\n  # This is **extension**  apply in Halite.\n  struct Params\n    # Returns the given key value pairs as a url"
  },
  {
    "path": "src/halite/feature.cr",
    "chars": 1483,
    "preview": "module Halite\n  abstract class Feature\n    def initialize(**options)\n    end\n\n    # Cooks with request\n    def request(r"
  },
  {
    "path": "src/halite/features/cache.cr",
    "chars": 5732,
    "preview": "require \"json\"\nrequire \"digest\"\nrequire \"file_utils\"\n\nmodule Halite\n  # Cache feature use for caching HTTP response to l"
  },
  {
    "path": "src/halite/features/logging/common.cr",
    "chars": 3620,
    "preview": "class Halite::Logging\n  # Common logging format\n  #\n  # Instance variables to check `Halite::Logging::Abstract`\n  #\n  # "
  },
  {
    "path": "src/halite/features/logging/json.cr",
    "chars": 2762,
    "preview": "require \"json\"\n\nclass Halite::Logging\n  # JSON logging format\n  #\n  # Instance variables to check `Halite::Logging::Abst"
  },
  {
    "path": "src/halite/features/logging.cr",
    "chars": 2874,
    "preview": "require \"log\"\nrequire \"colorize\"\nrequire \"file_utils\"\n\nLog.setup do |c|\n  backend = Log::IOBackend.new(formatter: Halite"
  },
  {
    "path": "src/halite/form_data.cr",
    "chars": 2677,
    "preview": "require \"http/formdata\"\nrequire \"mime/multipart\"\n\nmodule Halite\n  # Utility-belt to build form data request bodies.\n  #\n"
  },
  {
    "path": "src/halite/header_link.cr",
    "chars": 1631,
    "preview": "module Halite\n  # HeaderLink\n  #\n  # ref: [https://tools.ietf.org/html/rfc5988](https://tools.ietf.org/html/rfc5988)\n  s"
  },
  {
    "path": "src/halite/mime_type.cr",
    "chars": 762,
    "preview": "module Halite\n  module MimeType\n    @@adapters = {} of String => MimeType::Adapter\n    @@aliases = {} of String => Strin"
  },
  {
    "path": "src/halite/mime_types/json.cr",
    "chars": 253,
    "preview": "require \"json\"\n\nmodule Halite::MimeType\n  class JSON < Adapter\n    def encode(obj)\n      obj.to_json\n    end\n\n    def de"
  },
  {
    "path": "src/halite/options/follow.cr",
    "chars": 632,
    "preview": "module Halite\n  class Options\n    struct Follow\n      # No follow by default\n      DEFAULT_HOPS = 0\n\n      # A maximum o"
  },
  {
    "path": "src/halite/options/timeout.cr",
    "chars": 1216,
    "preview": "module Halite\n  class Options\n    # Timeout struct\n    struct Timeout\n      getter connect : Float64?\n      getter read "
  },
  {
    "path": "src/halite/options.cr",
    "chars": 15129,
    "preview": "require \"openssl\"\nrequire \"./options/*\"\n\nmodule Halite\n  # Options class\n  #\n  # ### Init with splats options\n  #\n  # ``"
  },
  {
    "path": "src/halite/rate_limit.cr",
    "chars": 880,
    "preview": "module Halite\n  # Limit Rate\n  #\n  # ref: [https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html](https://tool"
  },
  {
    "path": "src/halite/redirector.cr",
    "chars": 2607,
    "preview": "module Halite\n  class Redirector\n    # HTTP status codes which indicate redirects\n    REDIRECT_CODES = [300, 301, 302, 3"
  },
  {
    "path": "src/halite/request.cr",
    "chars": 2642,
    "preview": "module Halite\n  class Request\n    # Allowed methods\n    #\n    # See more: [https://github.com/crystal-lang/crystal/blob/"
  },
  {
    "path": "src/halite/response.cr",
    "chars": 3786,
    "preview": "module Halite\n  class Response\n    def self.new(uri : URI, status_code : Int32, body : String? = nil, headers = HTTP::He"
  },
  {
    "path": "src/halite.cr",
    "chars": 1582,
    "preview": "require \"./halite/*\"\nrequire \"./halite/ext/*\"\n\nmodule Halite\n  extend Chainable\n\n  VERSION = \"0.12.1\"\n\n  module Helper\n "
  }
]

About this extraction

This page contains the full source code of the icyleaf/halite GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 56 files (242.5 KB), approximately 65.9k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!