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
[](https://github.com/crystal-lang/crystal)
[](https://github.com/icyleaf/halite/blob/master/CHANGELOG.md)
[](https://github.com/icyleaf/halite/)
[](https://icyleaf.github.io/halite/)
[](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[
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": "\n\n# Halite\n\n[\" 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.