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 "

Redirect to ${DEFAULT_VERSION}

" > "${DOCS_PATH}/index.html" ================================================ FILE: .github/workflows/api-document.yml ================================================ name: Deploy API documents on: push: paths-ignore: - "benchmarks/**" branches: - "master" tags: - "v*" env: DOCS_PATH: docs GH_REF: https://github.com/icyleaf/halite GH_URL: https://icyleaf.github.io/halite jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@master with: # fetch all tags and branches fetch-depth: 0 - uses: oprypin/install-crystal@v1 - name: Generate id: generate run: | chmod +x .github/scripts/generate_docs.sh ./.github/scripts/generate_docs.sh - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ${{ env.DOCS_PATH }} ================================================ FILE: .github/workflows/linux-ci.yml ================================================ name: Linux CI on: push: paths-ignore: - "benchmarks/**" branches: - "master" pull_request: branches: "*" jobs: specs: runs-on: ubuntu-latest strategy: fail-fast: false matrix: crystal: [ '1.0.0', 'latest', 'nightly' ] name: Crystal ${{ matrix.crystal }} tests steps: - uses: actions/checkout@master - uses: oprypin/install-crystal@v1 with: crystal: ${{ matrix.crystal }} - name: Install dependencies run: shards install - name: Run tests run: crystal spec --error-on-warnings --error-trace - name: Run code format check run: | if ! crystal tool format --check; then crystal tool format git diff exit 1 fi ================================================ FILE: .github/workflows/release-version.yml ================================================ name: Deploy new release on: push: tags: - "v*" jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} draft: false prerelease: false ================================================ FILE: .gitignore ================================================ docs/ lib/ bin/ logs/ .shards/ # Local test file main.cr # Libraries don't need dependency lock # Dependencies will be locked in application that uses them shard.lock .history/ cache/ ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] > List all changes before release a new version. ### Todo - [ ] Rewrite HTTP Connection - [ ] New Connection for Halite - [x] Proxy support - [ ] Reuse connection ## [0.12.1] (2021-11-04) ### Fixed - Reduce alloc too many memory. [#108](https://github.com/icyleaf/halite/pull/108) (thanks @[wolfgang371](https://github.com/wolfgang371)) ## [0.12.0] (2021-03-24) ### Fixed - Compatibility with Crystal 1.0. ## [0.11.0] (2021-02-18) > Finally, the major version was out! Happy new year! ### Changed - **[breaking changing]** Drop file logging in favor of Crystal's [Log](https://crystal-lang.org/api/0.36.1/Log.html). (removed `.logging(file: )`, use `.logging(for: )` instead) [#101](https://github.com/icyleaf/halite/pull/101) (thanks @[oprypin](https://github.com/oprypin)) - Pre-read `TZ` environment value to convert timestamp's timezone during logging output. [#102](https://github.com/icyleaf/halite/pull/102) - Crystal 0.34.x support. ## [0.10.9] (2021-02-01) ### Fixed - `timeout` fail to match argument type. [#97](https://github.com/icyleaf/halite/issues/97) (thanks @[oprypin](https://github.com/oprypin)) - Compatibility with Crystal 0.36.0. ## [0.10.8] (2020-12-22) ### Fixed - Resolve path of endpoint ending without slash. [#94](https://github.com/icyleaf/halite/issues/94) (thanks @[mipmip](https://github.com/mipmip)) ## [0.10.7] (2020-12-08) ### Fixed - Fix initial status_message. [#91](https://github.com/icyleaf/halite/issues/91) (thanks @[oprypin](https://github.com/oprypin)) ## [0.10.6] (2020-11-24) ### Fixed - Improve resolve of URI. [#88](https://github.com/icyleaf/halite/issues/88) (thanks @[oprypin](https://github.com/oprypin)) ## [0.10.5] (2020-04-15) ### Fixed - Compatibility with Crystal 0.34.0. ## [0.10.4] (2019-09-26) ### Fixed - Compatibility with Crystal 0.31.0. ## [0.10.3] (2019-08-12) ### Fixed - Compatibility with Crystal 0.30.0. ## [0.10.2] (2019-06-24) ### Fixed - Fixed Basic Auth creates bad headers in crystal 0.29.0. [#73](https://github.com/icyleaf/halite/pull/73) (thanks @[kalinon](https://github.com/kalinon)) - Fixed use one shared options in multiple instanced `Halite::Client`. [#72](https://github.com/icyleaf/halite/issues/72) (thanks @[qszhu](https://github.com/qszhu)) ## [0.10.1] (2019-05-28) ### Fixed - Fixed duplica query and backslash when reuse client. [#67](https://github.com/icyleaf/halite/pull/67), [#68](https://github.com/icyleaf/halite/issues/68) (thanks @[watzon](https://github.com/watzon)) - Fixed no effect to call `logging(true)` method in Crystal 0.28. [#69](https://github.com/icyleaf/halite/issues/69) ## [0.10.0] (2019-05-20) ### Added - Add `endpoint` chainable method, also add it as configuration option to reuse client. [#66](https://github.com/icyleaf/halite/pull/66) ## [0.9.2] (2019-05-20) ### Fixed - Compatibility with Crystal 0.28.0 ### Changed - Drop Crystal 0.25.x, 0.26.x, 0.27.x support. ## [0.9.1] (2019-01-14) > Minor typo fix (same as v0.9.0) ### Fixed - Correct version both in `shard.yml` and `version.cr`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey)) - Update basic auth example in `README.md`. (thanks @[matthewmcgarvey](https://github.com/matthewmcgarvey)) ## [0.9.0] (2018-12-21) > New features with performance improved. ### Added - Add streaming requests (feature to store binary data chunk by chunk) [#53](https://github.com/icyleaf/halite/pull/53) - Add `user_agent` to Chainable methods. [#55](https://github.com/icyleaf/halite/pull/55) ### Fixed - Fix overwrite the value with default headers when use `merge` or `merge!` method in `Halite::Options`. [#54](https://github.com/icyleaf/halite/pull/54) ### Changed - Remove default headers in `Halite::Options`. - Move header `User-Agent` to `Halite::Request`. - Change header `Connection` from "keep-alive" to "close" to `Halite::Request`. - Remove header `Accept`. ## [0.8.0] (2018-11-30) > Compatibility with Crystal 0.27 and serious bugfix. ### Changed - **[breaking changing]** Rename `logger` to `logging`, `with_logger` to `with_logging`. [#52](https://github.com/icyleaf/halite/pull/52) - **[breaking changing]** Remove `logging` argument in `Halite::Options.new` and `Halite::Client.new`. [#51](https://github.com/icyleaf/halite/pull/51) - **[breaking changing]** Remove `logging?` method in `Halite::Options`, use `logging` method instead. [#51](https://github.com/icyleaf/halite/pull/51) - Change `logging` behavior check if features is exists any class of superclasses is `Halite::Logging` instead of given a Bool type. - Rename prefix `X-Cache` to `X-Halite-Cache` in cache feature. ### Added - Allow `timeout` method passed single `read` or `connect` method. - Add `merge!` and `dup` methods in `Halite::Options`. [#51](https://github.com/icyleaf/halite/pull/51) ### Fixed - Fix duplice add "Content-Type" into header during request. [#50](https://github.com/icyleaf/halite/pull/50) - Fix non overwrite value of headers use `Halite::Options.merge` method. [#50](https://github.com/icyleaf/halite/pull/50) - Fix always overwrite and return merged option in a instanced class(session mode), see updated note in [Session](https://github.com/icyleaf/halite#sessions). ### Tested - Compatibility with Crystal 0.27 - Add specs with Crystal 0.25, 0.26 and 0.27 in Circle CI. ## [0.7.5] (2018-10-31) ### Changed - **[breaking changing]** Rename argument name `ssl` to `tls` in `Halite::Client`/`Halite::Options`/`Halite::Chainable`. ### Fixed - Fix new a `Halite::Client` instance with empty block return `Nil`. [#44](https://github.com/icyleaf/halite/issues/44) ## [0.7.4] (2018-10-30) ### Fixed - Fix typos in document and comments. [#43](https://github.com/icyleaf/halite/issues/43) (thanks @[GloverDonovan](https://github.com/GloverDonovan)) ## [0.7.3] (2018-10-18) ### Fixed - Fix json payloads with sub hash/array/namedtupled. [#41](https://github.com/icyleaf/halite/issues/41) (thanks @[fusillicode](https://github.com/fusillicode)) ## [0.7.2] (2018-09-14) > Minor bugfix :bug: ### Changed - **[breaking changing]** Renamed `#to_h` to `#to_flat_h` to avoid confict in `HTTP::Params` extension. [#39](https://github.com/icyleaf/halite/issues/39) ### Fixed - Fix cast from NamedTuple(work: String) to Halite::Options::Type failed with params/json/form. [#38](https://github.com/icyleaf/halite/issues/38) ## [0.7.1] (2018-09-04) ### Changed - Return empty hash for an empty named tuple. ### Fixed - Fix send cookie during requesting in session mode. (thanks @[megatux](https://github.com/megatux)) - Fix pass current options instead of instance variable. - Fix move named tuple extension to src path. ## [0.7.0] (2018-09-03) > Features support :tada: ### Changed - **[breaking changing]** Change instance `Halite::Client` with block behavior. [#33](https://github.com/icyleaf/halite/issues/33) - **[breaking changing]** Renamed argument name `adapter` to `format` in `#logger` chainable method. - Move logger into features. ### Added - Add features (aka middleware) support, you can create monitor or interceptor. [#29](https://github.com/icyleaf/halite/issues/29) - Add cache feature. [#24](https://github.com/icyleaf/halite/issues/24) - Add `#logging` in chainable method. ### Fixed - Add misisng `#request` method with headers, params, form, json, raw, ssl arguments. - Fix do not overwrite default headers with exists one by using `Halite::Options.merge`. - Fix append response to history only with redirect uri. (thanks @[j8r](https://github.com/j8r)) - Typo and correct words in README. (thanks @[megatux](https://github.com/megatux)) ## [0.6.0] (2018-08-24) > Improve performance with :see_no_evil: ### Changed - **[breaking changing]** Set `logger` to nil when instance a `Halite::Options`, it throws a `Halite::Error` exception if enable `logging`. - Change `Halite::Options` accepts argument inside. no effect for users. [#27](https://github.com/icyleaf/halite/pull/27) - Wrap all exception class into a module, better for reading document. ### Fixed - Fix always return `#` with `#full_path` if fragment not exists in `Halite::Request`. - Fix always overwrite with default headers with `#merge` in `Halite::Options` ### Tested - Compatibility with Crystal 0.26 ## [0.5.0] (2018-07-03) ### Changed - New logger system and json logger support, see [#19](https://github.com/icyleaf/halite/pull/19). - **[breaking changing]** Change verb request behavior: - `get`, `head` only accepts `#params` argument. - `post`, `put`, `delete`, `patch`, `options` accepts `#params`, `#form`, `#json` and `#raw` arguments. ### Added - Add request [#raw](https://github.com/icyleaf/halite/#raw-string) string support. [#20](https://github.com/icyleaf/halite/issues/20) (thanks @[wirrareka](https://github.com/wirrareka)) ## [0.4.0] (2018-06-27) ### Changed - **[breaking changing]** Remove `#mime_type` duplicate with `#content_type` in `Halite::Response`. - Change write log file use append mode by default, it could be change by param. - Change logger formatter to easy identify category(request/response). ### Added - Add [#links](https://github.com/icyleaf/halite/#link-headers) to `Halite::Response` to fetch link headers. - Add [#raise_for_status](https://github.com/icyleaf/halite/#raise-for-status-code) to `Halite::Response`. - Support multiple files upload. [#14](https://github.com/icyleaf/halite/issues/14) (thanks @[BenDietze](https://github.com/BenDietze)) - Add `#to_raw` to `Halite::Response` to dump a raw of response. [#15](https://github.com/icyleaf/halite/issues/15) (thanks @[BenDietze](https://github.com/BenDietze)) - Support `OPTIONS` method (crystal 0.25.0+) - Append write log to a file section to README. ### Fixed - Stripped the filename in a `multipart/form-data` body. [#16](https://github.com/icyleaf/halite/issues/16) (thanks @[BenDietze](https://github.com/BenDietze)) - Fix `#domain` in `Halite::Request` with subdomain. [#17](https://github.com/icyleaf/halite/pull/17) (thanks @[007lva](https://github.com/007lva)) - Create missing directories when use path to write log to a file. ## [0.3.2] (2018-06-19) ### Fixed Compatibility with Crystal 0.25 ## [0.3.1] (2017-12-13) ### Added - Set `Options.default_headers` to be public method. - Accept tuples options in `Options.new`. - Accept `follow`/`follow_strict` in `Options.new`. - Accept options block in `Options.new`. - Add logger during request and response (see [usage](README.md#logging)). - Alias method `Options.read_timeout` to `Options::Timeout.read`. - Alias method `Options.read_timeout=` to `Options::Timeout.read=`. - Alias method `Options.connect_timeout` to `Options::Timeout.connect`. - Alias method `Options.connect_timeout` to `Options::Timeout.connect=`. - Alias method `Options.follow=` to `Options::Timeout.follow.hops=`. - Alias method `Options.follow_strict` to `Options::Timeout.follow.strict`. - Alias method `Options.follow_strict=` to `Options::Timeout.follow.strict=`. ### Fixed - Fix store **Set-Cookies** in response and set **Cookies** in request in better way. - Fix cant not set connect/read timeout in `Options.new`. - Fix cant not overwrite default headers in `Options.new`. - Fix `Options.clear!` was not clear everything and restore default headers. ## [0.2.0] (2017-11-28) ### Changed - `HTTP::Headers#to_h` return string with each key if it contains one in array. ([commit#e057c47c](https://github.com/icyleaf/halite/commit/e057c47c4b587b27b2bae6871a1968299ce348f5)) ### Added - Add `Response#mime_type` method. - Add `Response#history` method to support full history of redirections. ([#8](https://github.com/icyleaf/halite/issues/8)) - Add `Response#parse` method that it better body parser of response with json and write custom adapter for MIME type. ([#9](https://github.com/icyleaf/halite/issues/9)) ### Fixed - Fix issue to first char of redirect uri is not slash(/). ([#11](https://github.com/icyleaf/halite/issues/11)) - Fix raise unsafe verbs in strict mode. ## [0.1.5] (2017-10-11) ### Changed - Only store cookies in Sessions shards. ([#7](https://github.com/icyleaf/halite/issues/7)) ### Added - Add `TLS/SSL` support (based on [HTTP::Client.new(uri : URI, tls = nil)](https://crystal-lang.org/api/0.23.1/HTTP/Client.html#new%28uri%3AURI%2Ctls%3Dnil%29-class-method)). - Add `UnsupportedMethodError/UnsupportedSchemeError` exceptions. ### Fixed - Timeout with redirection. ([#7](https://github.com/icyleaf/halite/issues/7)) - Compatibility with Crystal 0.24.0 (unreleased) ## [0.1.3] (2017-10-09) ### Changed - Always instance a new Options with each request in chainable methods. ### Added - Add `accept` method. ### Fixed - Fix `follow`(redirect uri) with full uri and relative path. - Fix always overwrite request headers with default values. - Fix always shard same options in any new call. (it only valid in chainable methods) ## 0.1.2 (2017-09-18) - First beta version. [Unreleased]: https://github.com/icyleaf/halite/compare/v0.12.1...HEAD [0.12.1]: https://github.com/icyleaf/halite/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/icyleaf/halite/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/icyleaf/halite/compare/v0.10.9...v0.11.0 [0.10.9]: https://github.com/icyleaf/halite/compare/v0.10.8...v0.10.9 [0.10.8]: https://github.com/icyleaf/halite/compare/v0.10.7...v0.10.8 [0.10.7]: https://github.com/icyleaf/halite/compare/v0.10.6...v0.10.7 [0.10.6]: https://github.com/icyleaf/halite/compare/v0.10.5...v0.10.6 [0.10.5]: https://github.com/icyleaf/halite/compare/v0.10.4...v0.10.5 [0.10.4]: https://github.com/icyleaf/halite/compare/v0.10.3...v0.10.4 [0.10.3]: https://github.com/icyleaf/halite/compare/v0.10.2...v0.10.3 [0.10.2]: https://github.com/icyleaf/halite/compare/v0.10.1...v0.10.2 [0.10.1]: https://github.com/icyleaf/halite/compare/v0.10.0...v0.10.1 [0.10.0]: https://github.com/icyleaf/halite/compare/v0.9.2...v0.10.0 [0.9.2]: https://github.com/icyleaf/halite/compare/v0.9.1...v0.9.2 [0.9.1]: https://github.com/icyleaf/halite/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/icyleaf/halite/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/icyleaf/halite/compare/v0.7.5...v0.8.0 [0.7.5]: https://github.com/icyleaf/halite/compare/v0.7.4...v0.7.5 [0.7.4]: https://github.com/icyleaf/halite/compare/v0.7.3...v0.7.4 [0.7.3]: https://github.com/icyleaf/halite/compare/v0.7.2...v0.7.3 [0.7.2]: https://github.com/icyleaf/halite/compare/v0.7.1...v0.7.2 [0.7.1]: https://github.com/icyleaf/halite/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/icyleaf/halite/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/icyleaf/halite/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/icyleaf/halite/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/icyleaf/halite/compare/v0.3.2...v0.4.0 [0.3.2]: https://github.com/icyleaf/halite/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/icyleaf/halite/compare/v0.2.0...v0.3.1 [0.2.0]: https://github.com/icyleaf/halite/compare/v0.1.5...v0.2.0 [0.1.5]: https://github.com/icyleaf/halite/compare/v0.1.3...v0.1.5 [0.1.3]: https://github.com/icyleaf/halite/compare/v0.1.2...v0.1.3 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at icyleaf.cn@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017-present icyleaf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![halite-logo](https://github.com/icyleaf/halite/raw/master/halite-logo-small.png) # Halite [![Language](https://img.shields.io/badge/language-crystal-776791.svg)](https://github.com/crystal-lang/crystal) [![Tag](https://img.shields.io/github/tag/icyleaf/halite.svg)](https://github.com/icyleaf/halite/blob/master/CHANGELOG.md) [![Source](https://img.shields.io/badge/source-github-brightgreen.svg)](https://github.com/icyleaf/halite/) [![Document](https://img.shields.io/badge/document-api-brightgreen.svg)](https://icyleaf.github.io/halite/) [![Build Status](https://github.com/icyleaf/halite/workflows/Linux%20CI/badge.svg)](https://github.com/icyleaf/halite/actions?query=workflow%3A%22Linux+CI%22) HTTP Requests with a chainable REST API, built-in sessions and middleware written by [Crystal](https://crystal-lang.org/). Inspired from the **awesome** Ruby's [HTTP](https://github.com/httprb/http)/[RESTClient](https://github.com/rest-client/rest-client) and Python's [requests](https://github.com/requests/requests). Build in Crystal version `>= 1.0.0`, this document valid with latest commit. ## Index - [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) ## 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 # => ##}> ``` #### 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 # => [ # # "/relative-redirect/2" ...>, # # "/relative-redirect/1" ...>, # # "/get" ...>, # # "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 "], "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 # => # => Redirecting... # =>

Redirecting...

# =>

You should be redirected automatically to target URL: /cookies. 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 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("Mock Server is running.") 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(";") 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{; 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{;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{; 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{; 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{; 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{# "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(//) 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(//) 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(//) 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(//) 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(//) 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("Mock Server is running.") 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 "Mock Server is running." 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 "Mock Server is running." 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 "Mock Server is running." context end # DELETE delete "/" do |context| context.response.status_code = 200 context.response.content_type = "text/html" context.response.print "Mock Server is running." context end # HEAD head "/" do |context| context.response.status_code = 200 context.response.content_type = "text/html" context.response.print "Mock Server is running." context end # PATCH patch "/" do |context| context.response.status_code = 200 context.response.content_type = "text/html" context.response.print "Mock Server is running." context end # OPTIONS options "/" do |context| context.response.status_code = 200 context.response.content_type = "text/html" context.response.print "Mock Server is running." 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["Accepts"] # => ["application/json", "text/html"] # headers["Content-Type"] # => "text/html" # ``` def to_flat_h @hash.each_with_object({} of String => String | Array(String)) do |(key, values), obj| obj[key.name] = case values when String values.as(String) when Array values.size == 1 ? values[0].as(String) : values.as(Array(String)) else raise Halite::Error.new("Not support type `#{values.class} with value: #{values}") end end end end end ================================================ FILE: src/halite/ext/http_params_encode.cr ================================================ module HTTP # This is **extension** apply in Halite. struct Params # Returns the given key value pairs as a url-encoded query. # # Every parameter added is directly written to an IO, where keys and values are properly escaped. # # ``` # HTTP::Params.encode({ # "name" => "Lizeth Gusikowski", # "skill" => ["ruby", "crystal"], # "company" => { # "name" => "Keeling Inc", # }, # "avatar" => File.open("avatar_big.png"), # }) # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" # ``` def self.encode(hash : Hash) : String ::HTTP::Params.build do |form| hash.each do |key, value| key = key.to_s case value when Array value.each do |item| form.add("#{key}", item.to_s) end when File form.add(key, value.as(File).path) when Hash value.each do |hkey, hvalue| form.add("#{key}[#{hkey}]", hvalue.to_s) end else form.add(key, value.to_s) end end end end # Returns the given key value pairs as a url-encoded query. # # Every parameter added is directly written to an IO, where keys and values are properly escaped. # # ``` # HTTP::Params.encode({ # name: "Lizeth Gusikowski", # skill: ["ruby", "crystal"], # company: { # name: "Keeling Inc", # }, # avatar: File.open("avatar_big.png" # }) # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" # ``` def self.encode(named_tuple : NamedTuple) : String encode(named_tuple.to_h) end # Returns the given key value pairs as a url-encoded query. # # Every parameter added is directly written to an IO, where keys and values are properly escaped. # # ``` # HTTP::Params.encode( # name: "Lizeth Gusikowski", # skill: ["ruby", "crystal"], # company: { # name: "Keeling Inc", # }, # avatar: File.open("avatar_big.png" # ) # # => "name=Lizeth+Gusikowski&skill=ruby&skill=crystal&company=%7B%22name%22+%3D%3E+%22Keeling+Inc%22%7D&avatar=avatar_big.png" # ``` def self.encode(**named_tuple) : String encode(named_tuple) end end end ================================================ FILE: src/halite/feature.cr ================================================ module Halite abstract class Feature def initialize(**options) end # Cooks with request def request(request : Halite::Request) : Halite::Request request end # Cooking with response def response(response : Halite::Response) : Halite::Response response end # Intercept and cooking request and response def intercept(chain : Halite::Feature::Chain) : Halite::Feature::Chain chain end # Feature chain # # Chain has two result: # # next: perform and run next interceptor # return: perform and return class Chain enum Result Next Return end property request getter response getter result @performed_response : Halite::Response? def initialize(@request : Halite::Request, @response : Halite::Response?, @options : Halite::Options, &block : -> Halite::Response) @result = Result::Next @performed_response = nil @perform_request_block = block end def next(response) @result = Result::Next @response = response self end def return(response) @result = Result::Return @response = response self end def performed? !@performed_response.nil? end def perform @performed_response ||= @perform_request_block.call @performed_response.not_nil! end end end end require "./features/*" ================================================ FILE: src/halite/features/cache.cr ================================================ require "json" require "digest" require "file_utils" module Halite # Cache feature use for caching HTTP response to local storage to speed up in developing stage. # # 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) # # ``` # 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"}} # ``` class Cache < Feature DEFAULT_PATH = "/tmp/halite/cache/" getter file : String? getter path : String getter expires : Time::Span? getter debug : Bool # return a new Cache instance # # Accepts argument: # # - **debug**: `Bool` # - **path**: `String` # - **expires**: `(Int32 | Time::Span)?` def initialize(**options) @debug = options.fetch(:debug, true).as(Bool) if file = options[:file]? @file = file @path = DEFAULT_PATH @expires = nil else @file = nil @path = options.fetch(:path, DEFAULT_PATH).as(String) @expires = case expires = options[:expires]? when Time::Span expires.as(Time::Span) when Int32 Time::Span.new(seconds: expires.as(Int32), nanoseconds: 0) when Nil nil else raise "Only accept Int32 and Time::Span type." end end end def intercept(chain) response = cache(chain) do chain.perform end chain.return(response) end private def cache(chain, &block : -> Halite::Response) if response = find_cache(chain.request) return response end response = yield write_cache(chain.request, response) response end private def find_cache(request : Halite::Request) : Halite::Response? if file = @file build_response(request, file) elsif response = build_response(request) response end end private def find_file(file) : Halite::Response raise Error.new("Not find cache file: #{file}") if File.file?(file) build_response(file) end private def build_response(request : Halite::Request, file : String? = nil) : Halite::Response? status_code = 200 headers = HTTP::Headers.new cache_from = "file" unless file # Cache in path key = generate_cache_key(request) path = File.join(@path, key) return unless Dir.exists?(path) cache_from = "cache" cache_file = File.join(path, "#{key}.cache") if File.file?(cache_file) && !cache_expired?(cache_file) file = cache_file if metadata = find_metadata(path) status_code = metadata["status_code"].as_i metadata["headers"].as_h.each do |name, value| headers[name] = value.as_s end end if @debug headers["X-Halite-Cached-Key"] = key headers["X-Halite-Cached-Expires-At"] = @expires ? (cache_created_time(file) + @expires.not_nil!).to_s : "None" end end end return unless file if @debug headers["X-Halite-Cached-From"] = cache_from headers["X-Halite-Cached-At"] = cache_created_time(file).to_s end body = File.read_lines(file).join("\n") Response.new(request.uri, status_code, body, headers) end private def find_metadata(path) file = File.join(path, "metadata.json") if File.exists?(file) JSON.parse(File.open(file)).as_h end end private def cache_expired?(file) return false unless expires = @expires file_modified_time = cache_created_time(file) Time.utc >= (file_modified_time + expires) end private def cache_created_time(file) File.info(file).modification_time end private def generate_cache_key(request) : String Digest::MD5.hexdigest("#{request.verb}-#{request.uri}-#{request.body}") end private def write_cache(request, response) key = generate_cache_key(request) path = File.join(@path, key) FileUtils.mkdir_p(path) unless Dir.exists?(path) write_metadata(path, response) write_body(path, key, response) end private def write_metadata(path, response) File.open(File.join(path, "metadata.json"), "w") do |f| f.puts({ "status_code" => response.status_code, "headers" => response.headers.to_flat_h, }.to_json) end end private def write_body(path, key, response) File.open(File.join(path, "#{key}.cache"), "w") do |f| f.puts response.body end end Halite.register_feature "cache", self end end ================================================ FILE: src/halite/features/logging/common.cr ================================================ class Halite::Logging # Common logging format # # Instance variables to check `Halite::Logging::Abstract` # # ``` # Halite.use("logging", logging: Halite::Logging::Common.new(skip_request_body: true)) # .get("http://httpbin.org/get") # # # Or # Halite.logging(format: "common", skip_request_body: true) # .get("http://httpbin.org/get") # # # => 2018-08-31 16:56:12 +08:00 | request | GET | http://httpbin.org/get # # => 2018-08-31 16:56:13 +08:00 | response | 200 | http://httpbin.org/get | 1.08s | application/json # ``` class Common < Abstract def request(request) message = String.build do |io| io << "> | request | " << colorful_method(request.verb) io << "| " << request.uri unless request.body.empty? || @skip_request_body io << "\n" << request.body end end @logger.info { message } @request_time = Time.utc unless @skip_benchmark end def response(response) message = String.build do |io| content_type = response.content_type || "Unknown MIME" io << "< | response | " << colorful_status_code(response.status_code) io << "| " << response.uri if !@skip_benchmark && (request_time = @request_time) elapsed = Time.utc - request_time io << " | " << human_time(elapsed) end io << " | " << content_type unless response.body.empty? || binary_type?(content_type) || @skip_response_body io << "\n" << response.body end end @logger.info { message } end protected def colorful_method(method, is_request = true) fore, back = case method.upcase when "GET" [:white, :blue] when "POST" [:white, :cyan] when "PUT" [:white, :yellow] when "DELETE" [:white, :red] when "PATCH" [:white, :green] when "HEAD" [:white, :magenta] else [:dark_gray, :white] end colorful((" %-7s" % method), fore, back) end protected def colorful_status_code(status_code : Int32) fore, back = case status_code when 300..399 [:dark_gray, :white] when 400..499 [:white, :yellow] when 500..999 [:white, :red] else [:white, :green] end colorful((" %-7s" % status_code), fore, back) end protected def colorful(message, fore, back) Colorize.enabled = !!(@colorize && (backend = @logger.backend.as?(Log::IOBackend)) && backend.io.tty?) message.colorize.fore(fore).back(back) end # return `true` if is binary types with MIME type # # MIME types list: https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types private def binary_type?(content_type) binary_types = %w(image audio video) application_types = %w(pdf octet-stream ogg 3gpp ebook archive rar zip tar 7z word powerpoint excel flash font) binary_types.each do |name| return true if content_type.starts_with?(name) end application_types.each do |name| return true if content_type.starts_with?("application") && content_type.includes?(name) end false end Logging.register "common", self end end ================================================ FILE: src/halite/features/logging/json.cr ================================================ require "json" class Halite::Logging # JSON logging format # # Instance variables to check `Halite::Logging::Abstract`. # # In JSON format, if you set skip some key, it will return `false`. # # ``` # Halite.use("logging", logging: Halite::Logging::JSON.new(skip_request_body: true)) # .get("http://httpbin.org/get") # # # Or # Halite.logging(format: "json", skip_request_body: true) # .get("http://httpbin.org/get") # ``` # # Log will look like: # # ``` # { # "created_at": "2018-08-31T16:53:57+08:00:00", # "entry": { # "request": { # "body": "", # "headers": {...}, # "method": "GET", # "url": "http://httpbin.org/anything", # "timestamp": "2018-08-31T16:53:59+08:00:00", # }, # "response": { # "body": false, # "header": {...}, # "status_code": 200, # "http_version": "HTTP/1.1", # "timestamp": "2018-08-31T16:53:59+08:00:00", # }, # }, # } # ``` class JSON < Abstract @request : Request? = nil @response : Response? = nil def request(request) @request_time = Time.utc @request = Request.new(request, @skip_request_body) end def response(response) @response = Response.new(response, @skip_response_body) @logger.info { raw } end private def raw elapsed : String? = nil if !@skip_benchmark && (request_time = @request_time) elapsed = human_time(Time.utc - request_time) end { "created_at" => Helper.to_rfc3339(@request_time.not_nil!), "elapsed" => elapsed, "entry" => { "request" => @request.not_nil!.to_h, "response" => @response.not_nil!.to_h, }, }.to_pretty_json end # :nodoc: private struct Request def initialize(@request : Halite::Request, @skip_body = false) end def to_h { "body" => @skip_body ? false : @request.body, "headers" => @request.headers.to_flat_h, "method" => @request.verb, "url" => @request.uri.to_s, "timestamp" => Helper.to_rfc3339(Time.utc), } end end # :nodoc: private struct Response def initialize(@response : Halite::Response, @skip_body = false) end def to_h { "body" => @skip_body ? false : @response.body, "header" => @response.headers.to_flat_h, "status_code" => @response.status_code, "http_version" => @response.version, "timestamp" => Helper.to_rfc3339(Time.utc), } end end Logging.register "json", self end end ================================================ FILE: src/halite/features/logging.cr ================================================ require "log" require "colorize" require "file_utils" Log.setup do |c| backend = Log::IOBackend.new(formatter: Halite::Logging::ShortFormat) c.bind("halite", :info, backend) end module Halite # Logging feature class Logging < Feature DEFAULT_LOGGER = Logging::Common.new getter writer : Logging::Abstract # return a new Cache instance # # Accepts argument: # # - **logging**: `Logging::Abstract` def initialize(**options) @writer = (logging = options[:logging]?) ? logging.as(Logging::Abstract) : DEFAULT_LOGGER end def request(request) @writer.request(request) request end def response(response) @writer.response(response) response end # Logging format Abstract abstract class Abstract setter logger : Log getter skip_request_body : Bool getter skip_response_body : Bool getter skip_benchmark : Bool getter colorize : Bool @request_time : Time? def initialize(*, for : String = "halite", @skip_request_body = false, @skip_response_body = false, @skip_benchmark = false, @colorize = true) @logger = Log.for(for) Colorize.enabled = @colorize end abstract def request(request) abstract def response(response) protected def human_time(elapsed : Time::Span) elapsed = elapsed.to_f case Math.log10(elapsed) when 0..Float64::MAX digits = elapsed suffix = "s" when -3..0 digits = elapsed * 1000 suffix = "ms" when -6..-3 digits = elapsed * 1_000_000 suffix = "µs" else digits = elapsed * 1_000_000_000 suffix = "ns" end "#{digits.round(2).to_s}#{suffix}" end end @@formats = {} of String => Abstract.class # Logging format register module Register def register(name : String, format : Abstract.class) @@formats[name] = format end def [](name : String) @@formats[name] end def []?(name : String) @@formats[name]? end def availables @@formats.keys end end # Similar to `Log::ShortFormat` # # **NOTE**: It invalid by calling `Log.setup` or `Log.setup_from_env` outside of Halite. # # Copy from https://github.com/crystal-lang/crystal/blob/3c48f311f/src/log/format.cr#L197 struct ShortFormat < Log::StaticFormatter def run "#{timestamp} - #{source(before: " ", after: ": ")}#{message}" \ "#{data(before: " -- ")}#{context(before: " -- ")}#{exception}" end def timestamp Helper.to_rfc3339(@entry.timestamp, @io) end end extend Register Halite.register_feature "logging", self end end require "./logging/*" ================================================ FILE: src/halite/form_data.cr ================================================ require "http/formdata" require "mime/multipart" module Halite # Utility-belt to build form data request bodies. # # Provides support for `application/x-www-form-urlencoded` and # `multipart/form-data` types. # # ``` # form = FormData.create({ # "name" => "Lizeth Gusikowski", # "skill" => ["ruby", "crystal"], # "avatar" => File.open("avatar.png"), # => "image binary data" # }) # # form.body # => "----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nLizeth Gusikowski\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"skill\"\r\n\r\nruby\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"skill\"\r\n\r\ncrystal\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\n\r\nimage binary data\n\r\n----------------------------_ytTht-0D5oif0cAGXSPjPSN--" # form.headers # => HTTP::Headers{"Content-Type" => "multipart/form-data; boundary=\"--------------------------SS0a9QKeM_6fcj2CE5D4d0LQ\""} # ``` module FormData # FormData factory. Automatically selects best type depending on given `data` Hash def self.create(data : Hash(String, Halite::Options::Type) = {} of String => Halite::Options::Type) : Halite::Request::Data if multipart?(data) io = IO::Memory.new builder = HTTP::FormData::Builder.new(io) data.each do |k, v| case v when File builder.file(k, v.as(IO), HTTP::FormData::FileMetadata.new(filename: File.basename(v.path))) when Array v.each do |e| case e when File builder.file(k, e.as(IO), HTTP::FormData::FileMetadata.new(filename: File.basename(e.path))) else builder.field(k, e.to_s) end end else builder.field(k, v.to_s) end end builder.finish Halite::Request::Data.new(io.to_s, builder.content_type) else body = HTTP::Params.encode(data) Halite::Request::Data.new(body, "application/x-www-form-urlencoded") end end # Tells whenever data contains multipart data or not. private def self.multipart?(data : Hash(String, Halite::Options::Type)) : Bool data.any? do |_, v| case v when File next true when Array v.any? do |vv| next true if vv.is_a?(File) end else false end end end end end ================================================ FILE: src/halite/header_link.cr ================================================ module Halite # HeaderLink # # ref: [https://tools.ietf.org/html/rfc5988](https://tools.ietf.org/html/rfc5988) struct HeaderLink # Header link parser def self.parse(raw : String, uri : URI? = nil) : Hash(String, Halite::HeaderLink) links = {} of String => HeaderLink raw.split(/,\s* String if raw.includes?(";") target, attrs = raw.split(";", 2) rel = target = target.gsub(/[<> '\"]/, "").strip unless attrs.strip.empty? attrs.split(";").each do |attr| next if attr.strip.empty? key, value = attr.split("=") key = key.gsub(/['\"]/, "").strip next if params.has_key?(key) value = value.gsub(/['\"]/, "").strip params[key] = value end if name = params.delete("rel") rel = name if target == "/" target = rel elsif target.starts_with?("/") && (uri_local = uri) full_target = uri_local.dup full_target.path = target target = full_target.to_s end end end else rel = target = raw.gsub(/[<> '\"]/, "").strip end new(rel, target, params) end getter rel, target, params def initialize(@rel : String, @target : String, @params : Hash(String, String)) end def to_s(io) io << target end end end ================================================ FILE: src/halite/mime_type.cr ================================================ module Halite module MimeType @@adapters = {} of String => MimeType::Adapter @@aliases = {} of String => String def self.register(adapter : MimeType::Adapter, name : String, *shortcuts) @@adapters[name] = adapter shortcuts.each do |shortcut| next unless shortcut.is_a?(String) @@aliases[shortcut] = name end unless shortcuts.empty? end def self.[](name : String) @@adapters[normalize name] end def self.[]?(name : String) @@adapters[normalize name]? end private def self.normalize(name : String) @@aliases.fetch name, name end abstract class Adapter abstract def encode(obj) abstract def decode(string) end end end require "./mime_types/*" ================================================ FILE: src/halite/mime_types/json.cr ================================================ require "json" module Halite::MimeType class JSON < Adapter def encode(obj) obj.to_json end def decode(str) ::JSON.parse str end end end Halite::MimeType.register Halite::MimeType::JSON.new, "application/json", "json" ================================================ FILE: src/halite/options/follow.cr ================================================ module Halite class Options struct Follow # No follow by default DEFAULT_HOPS = 0 # A maximum of 5 subsequent redirects MAX_HOPS = 5 # Redirector hops policy STRICT = true property hops : Int32 property strict : Bool def initialize(hops : Int32? = nil, strict : Bool? = nil) @hops = hops || DEFAULT_HOPS @strict = strict.nil? ? STRICT : strict end def strict? @strict == true end def updated? @hops != DEFAULT_HOPS || @strict != STRICT end end end # :nodoc: alias Follow = Options::Follow end ================================================ FILE: src/halite/options/timeout.cr ================================================ module Halite class Options # Timeout struct struct Timeout getter connect : Float64? getter read : Float64? getter write : Float64? def initialize(connect : (Int32 | Float64 | Time::Span)? = nil, read : (Int32 | Float64 | Time::Span)? = nil, write : (Int32 | Float64 | Time::Span)? = nil) @connect = timeout_value(connect) @read = timeout_value(read) @write = timeout_value(write) end def connect=(connect : (Int32 | Float64 | Time::Span)?) @connect = timeout_value(connect) end def read=(read : (Int32 | Float64 | Time::Span)?) @read = timeout_value(read) end def write=(write : (Int32 | Float64 | Time::Span)?) @write = timeout_value(write) end private def timeout_value(value : (Int32 | Float64 | Time::Span)? = nil) : Float64? case value when Int32 value.as(Int32).to_f when Float64 value.as(Float64) when Time::Span value.as(Time::Span).total_seconds.to_f else nil end end end end # :nodoc: alias Timeout = Options::Timeout end ================================================ FILE: src/halite/options.cr ================================================ require "openssl" require "./options/*" module Halite # Options class # # ### Init with splats options # # ``` # o = Options.new( # headers: { # user_agent: "foobar" # } # } # o.headers.class # => HTTP::Headers # o.cookies.class # => HTTP::Cookies # ``` # # ### Set/Get timeout # # Set it with `connect_timeout`/`read_timeout`/`write_timeout` keys, # but get it call `Timeout` class. # # ``` # o = Options.new(connect_timeout: 30, read_timeout: 30) # o.timeout.connect # => 30.0 # o.timeout.read # => 30.0 # o.timeout.write # => nil # ``` # # ### Set/Get follow # # Set it with `follow`/`follow_strict` keys, but get it call `Follow` class. # # ``` # o = Options.new(follow: 3, follow_strict: false) # o.follow.hops # => 3 # o.follow.strict # => false # ``` class Options def self.new(options : Halite::Options) options.is_a?(self) ? options : super end 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, connect_timeout : (Int32 | Float64 | Time::Span)? = nil, read_timeout : (Int32 | Float64 | Time::Span)? = nil, write_timeout : (Int32 | Float64 | Time::Span)? = nil, follow : Int32? = nil, follow_strict : Bool? = nil, tls : OpenSSL::SSL::Context::Client? = nil, features = {} of String => Feature) new( endpoint: endpoint, headers: headers, cookies: cookies, params: params, form: form, json: json, raw: raw, timeout: Timeout.new(connect: connect_timeout, read: read_timeout, write: write_timeout), follow: Follow.new(hops: follow, strict: follow_strict), tls: tls, features: features ) end # Types of options in a Hash alias Type = Nil | Symbol | String | Int32 | Int64 | Float64 | Bool | File | Array(Type) | Hash(String, Type) property endpoint : URI? property headers : HTTP::Headers property cookies : HTTP::Cookies property timeout : Timeout property follow : Follow property tls : OpenSSL::SSL::Context::Client? property params : Hash(String, Type) property form : Hash(String, Type) property json : Hash(String, Type) property raw : String? property features : Hash(String, Feature) def initialize(*, 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, @features = {} of String => Feature) @endpoint = parse_endpoint(endpoint) @headers = parse_headers(headers) @cookies = parse_cookies(cookies) @params = parse_params(params) @form = parse_form(form) @json = parse_json(json) end def initialize(*, @endpoint : URI?, @headers : HTTP::Headers, @cookies : HTTP::Cookies, @params : Hash(String, Type), @form : Hash(String, Type), @json : Hash(String, Type), @raw : String? = nil, @timeout = Timeout.new, @follow = Follow.new, @tls : OpenSSL::SSL::Context::Client? = nil, @features = {} of String => Feature) end def with_endpoint(endpoint : String | URI) self.endpoint = endpoint self end # Alias `with_headers` method. def with_headers(**with_headers) : Halite::Options with_headers(with_headers) end # Returns `Options` self with given headers combined. def with_headers(headers : Hash(String, _) | NamedTuple) : Halite::Options @headers.merge!(parse_headers(headers)) self end # Alias `with_cookies` method. def with_cookies(**cookies) : Halite::Options with_cookies(cookies) end # Returns `Options` self with given cookies combined. def with_cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Options cookies.each do |key, value| @cookies[key.to_s] = value.to_s end self end # Returns `Options` self with given cookies combined. def with_cookies(cookies : HTTP::Cookies) : Halite::Options cookies.each do |cookie| with_cookies(cookie) end self end # Returns `Options` self with given cookies combined. def with_cookies(cookie : HTTP::Cookie) : Halite::Options cookie_header = HTTP::Headers{"Cookie" => cookie.to_cookie_header} @headers.merge!(cookie_header) @cookies.fill_from_client_headers(@headers) self end # Returns `Options` self with given max hops of redirect times. # # ``` # # Automatically following redirects # options.with_follow # # A maximum of 3 subsequent redirects # options.with_follow(3) # # Set subsequent redirects # options.with_follow(3) # ``` def with_follow(follow = Follow::MAX_HOPS, strict = Follow::STRICT) : Halite::Options @follow.hops = follow @follow.strict = strict self end # Returns `Options` self with given connect, read timeout. def with_timeout(connect : (Int32 | Float64 | Time::Span)? = nil, read : (Int32 | Float64 | Time::Span)? = nil, write : (Int32 | Float64 | Time::Span)? = nil) : Halite::Options @timeout.connect = connect.to_f if connect @timeout.read = read.to_f if read @timeout.write = write.to_f if write self end # Returns `Options` self with the name of features. def with_features(*features) features.each do |feature| with_features(feature, NamedTuple.new) end self end # Returns `Options` self with feature name and options. def with_features(feature_name : String, **opts) with_features(feature_name, opts) end # Returns `Options` self with feature name and options. def with_features(name : String, opts : NamedTuple) raise UnRegisterFeatureError.new("Not available feature: #{name}") unless klass = Halite.feature?(name) @features[name] = klass.new(**opts) self end # Returns `Options` self with feature name and feature. def with_features(name : String, feature : Feature) @features[name] = feature self end # Returns `Options` iitself with given format and the options of format. def with_logging(format : String, **opts) raise UnRegisterLoggerFormatError.new("Not available logging format: #{format}") unless format_cls = Logging[format]? with_logging(format_cls.new(**opts)) end # Returns `Options` self with given logging, depend on `with_features`. def with_logging(logging : Halite::Logging::Abstract) with_features("logging", logging: logging) self end # Set endpoint of request def endpoint=(endpoint : String) @endpoint = URI.parse(endpoint) end # Set headers of request def headers=(headers : (Hash(String, _) | NamedTuple)) @headers = parse_headers(headers) end # Alias `Timeout.connect` def connect_timeout @timeout.connect end # Alias `Timeout.connect=` def connect_timeout=(timeout : Int32 | Float64 | Time::Span) @timeout.connect = timeout end # Alias `Timeout.read` def read_timeout @timeout.read end # Alias `Timeout.read=` def read_timeout=(timeout : Int32 | Float64 | Time::Span) @timeout.read = timeout end # Alias `Timeout.write` def write_timeout @timeout.write end # Alias `Timeout.write=` def write_timeout=(timeout : Int32 | Float64 | Time::Span) @timeout.write = timeout end # Alias `Follow.hops=` def follow=(hops : Int32) @follow.hops = hops end # Alias `Follow.strict` def follow_strict @follow.strict end # Alias `Follow.strict=` def follow_strict=(strict : Bool) @follow.strict = strict end # Get logging status def logging : Bool !@features.values.select { |v| v.is_a?(Halite::Logging) }.empty? end # Quick enable logging # # By defaults, use `Logging::Common` as logging output. def logging=(enable : Bool) if enable with_features("logging") unless logging else @features.delete("logging") end end # Merge with other `Options` and return new `Halite::Options` def merge(other : Halite::Options) : Halite::Options options = Halite::Options.new options.merge!(dup) options.merge!(other) options end # Merge with other `Options` and return self def merge!(other : Halite::Options) : Halite::Options @endpoint = other.endpoint if other.endpoint @headers.merge!(other.headers) other.cookies.each do |cookie| @cookies << cookie end if other.cookies != @cookies if other.timeout.connect || other.timeout.read || other.timeout.write @timeout = other.timeout end if other.follow.updated? @follow = other.follow end @features.merge!(other.features) unless other.features.empty? @params.merge!(other.params) if other.params @form.merge!(other.form) if other.form @json.merge!(other.json) if other.json @raw = other.raw if other.raw @tls = other.tls if other.tls self end # Reset options def clear! : Halite::Options @endpoint = nil @headers = HTTP::Headers.new @cookies = HTTP::Cookies.new @params = {} of String => Type @form = {} of String => Type @json = {} of String => Type @raw = nil @timeout = Timeout.new @follow = Follow.new @features = {} of String => Feature @tls = nil self end # Produces a shallow copy of obj—the instance variables of obj are copied, # but not the objects they reference. dup copies the tainted state of obj. def dup Halite::Options.new( endpoint: @endpoint, headers: @headers.dup, cookies: @cookies, params: @params, form: @form, json: @json, raw: @raw, timeout: @timeout, follow: @follow, features: @features, tls: @tls ) end # Returns this collection as a plain Hash. def to_h { "endpoint" => @endpoint, "headers" => @headers.to_h, "cookies" => @cookies.to_h, "params" => @params ? @params.to_h : nil, "form" => @form ? @form.to_h : nil, "json" => @json ? @json.to_h : nil, "raw" => @raw, "connect_timeout" => @timeout.connect, "read_timeout" => @timeout.read, "follow" => @follow.hops, "follow_strict" => @follow.strict, } end private def parse_endpoint(endpoint : (String | URI)?) : URI? case endpoint when String URI.parse(endpoint) when URI endpoint.as(URI) else nil end end private def parse_headers(raw : (Hash(String, _) | NamedTuple | HTTP::Headers)?) : HTTP::Headers case raw when Hash, NamedTuple HTTP::Headers.encode(raw) when HTTP::Headers raw.as(HTTP::Headers) else HTTP::Headers.new end end private def parse_cookies(raw : (Hash(String, _) | NamedTuple | HTTP::Cookies)?) : HTTP::Cookies cookies = HTTP::Cookies.from_client_headers(@headers) if objects = raw objects.each do |key, value| cookies[key] = case value when HTTP::Cookie value else value.to_s end end end cookies end private def parse_cookies(headers : HTTP::Headers) : HTTP::Cookies cookies = HTTP::Cookies.from_client_headers(headers) end {% for attr in %w(params form json) %} private def parse_{{ attr.id }}(raw : (Hash(String, _) | NamedTuple)?) : Hash(String, Options::Type) new_{{ attr.id }} = {} of String => Type return new_{{ attr.id }} unless {{ attr.id }} = raw if {{ attr.id }}.responds_to?(:each) {{ attr.id }}.each do |key, value| new_{{ attr.id }}[key.to_s] = case value when Array cast_hash(value.as(Array)) when Hash cast_hash(value.as(Hash)) when NamedTuple cast_hash(value.as(NamedTuple)) when Type value else value.as(Type) end end end new_{{ attr.id }} end {% end %} private def cast_hash(raw : Array) : Options::Type raw.each_with_object([] of Type) do |value, obj| obj << case value when Array cast_hash(value.as(Array)) when Hash cast_hash(value.as(Hash)) when NamedTuple cast_hash(value.as(NamedTuple)) else value.as(Type) end end.as(Type) end private def cast_hash(raw : Hash) : Options::Type raw.each_with_object({} of String => Type) do |(key, value), obj| if key.responds_to?(:to_s) obj[key.to_s] = case value when Array cast_hash(value.as(Array)) when Hash cast_hash(value.as(Hash)) when NamedTuple cast_hash(value.as(NamedTuple)) else value.as(Type) end end end.as(Type) end private def cast_hash(raw : NamedTuple) : Options::Type cast_hash(raw.to_h) end end end ================================================ FILE: src/halite/rate_limit.cr ================================================ module Halite # Limit Rate # # ref: [https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html](https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html) # # ``` # > X-RateLimit-Limit: 5000 # > X-RateLimit-Remaining: 4987 # > X-RateLimit-Reset: 1350085394 # ``` struct RateLimit RATELIMIT_LIMIT = "X-RateLimit-Limit" RATELIMIT_REMAINING = "X-RateLimit-Remaining" RATELIMIT_RESET = "X-RateLimit-Reset" def self.parse(headers : HTTP::Headers) limit = headers[RATELIMIT_LIMIT]?.try &.to_i remaining = headers[RATELIMIT_REMAINING]?.try &.to_i reset = headers[RATELIMIT_RESET]?.try &.to_i return if !limit && !remaining && !reset new(limit, remaining, reset) end getter limit, remaining, reset def initialize(@limit : Int32?, @remaining : Int32?, @reset : Int32?) end end end ================================================ FILE: src/halite/redirector.cr ================================================ module Halite class Redirector # HTTP status codes which indicate redirects REDIRECT_CODES = [300, 301, 302, 303, 307, 308] # Codes which which should raise StateError in strict mode if original # request was any of {UNSAFE_VERBS} STRICT_SENSITIVE_CODES = [300, 301, 302] # Insecure http verbs, which should trigger StateError in strict mode # upon {STRICT_SENSITIVE_CODES} UNSAFE_VERBS = %w(PUT DELETE POST) # Verbs which will remain unchanged upon See Other response. SEE_OTHER_ALLOWED_VERBS = %w(GET HEAD) def self.new(request : Halite::Request, response : Halite::Response, options : Halite::Options) new(request, response, options.follow.hops, options.follow.strict) end getter strict : Bool getter max_hops : Int32 # Instance a new Redirector def initialize(@request : Halite::Request, @response : Halite::Response, @max_hops = 5, @strict = true) @visited = [] of String end # Follows redirects until non-redirect response found def perform(&block : Halite::Request -> Halite::Response) : Halite::Response if avaiable? each_redirect do |request| block.call(request) end end @response end # Loop each redirect request with block call def each_redirect(&block : Halite::Request -> Halite::Response) while avaiable? @visited << "#{@request.verb} #{@request.uri}" raise TooManyRedirectsError.new if too_many_hops? raise EndlessRedirectError.new if endless_loop? @request = redirect_to(@response.headers["Location"]?) @response = block.call(@request) end end # Return `true` if it should redirect, else `false` def avaiable? REDIRECT_CODES.includes?(@response.status_code) end # Redirect policy for follow private def redirect_to(uri : String?) raise StateError.new("No found `Location` in headers") unless uri verb = @request.verb code = @response.status_code if UNSAFE_VERBS.includes?(verb) && STRICT_SENSITIVE_CODES.includes?(code) raise StateError.new("Can not follow #{code} redirect") if @strict verb = "GET" end verb = "GET" if !SEE_OTHER_ALLOWED_VERBS.includes?(verb) && code == 303 @request.redirect(uri, verb) end # Check if we reached max amount of redirect hops private def too_many_hops? : Bool 1 <= @max_hops && @max_hops < @visited.size end # Check if we got into an endless loop def endless_loop? 2 <= @visited.count(@visited.last) end end end ================================================ FILE: src/halite/request.cr ================================================ module Halite class Request # Allowed methods # # See more: [https://github.com/crystal-lang/crystal/blob/863f301cfe9e9757a6bf1a494ab7bf49bfc07a06/src/http/client.cr#L329](https://github.com/crystal-lang/crystal/blob/863f301cfe9e9757a6bf1a494ab7bf49bfc07a06/src/http/client.cr#L329) METHODS = %w(GET PUT POST DELETE HEAD PATCH OPTIONS) # Allowed schemes SCHEMES = %w(http https) # Request user-agent by default USER_AGENT = "Halite/#{Halite::VERSION}" # The verb name of request getter verb : String # The uri of request getter uri : URI # The scheme name of request getter scheme : String # The headers of request getter headers : HTTP::Headers # The payload of request getter body : String def initialize(verb : String, @uri : URI, @headers : HTTP::Headers = HTTP::Headers.new, @body : String = "") @verb = verb.upcase raise UnsupportedMethodError.new("Unknown method: #{@verb}") unless METHODS.includes?(@verb) raise UnsupportedSchemeError.new("Missing scheme: #{@uri}") unless @uri.scheme @scheme = @uri.scheme.not_nil! raise UnsupportedSchemeError.new("Unknown scheme: #{@scheme}") unless SCHEMES.includes?(@scheme) @headers["User-Agent"] ||= USER_AGENT @headers["Connection"] ||= "close" end # Returns new Request with updated uri def redirect(uri : String, verb = @verb) headers = @headers.dup headers.delete("Host") Request.new(verb, redirect_uri(domain, uri), headers, body) end # @return `URI` with the scheme, user, password, port and host combined def domain URI.new(@uri.scheme, @uri.host, @uri.port, "", nil, @uri.user, @uri.password, nil) end # @return `String` with the path, query and fragment combined def full_path String.build do |str| {% if Crystal::VERSION < "0.36.0" %} str << @uri.full_path {% else %} str << @uri.request_target {% end %} if @uri.fragment str << "#" << @uri.fragment end end end private def redirect_uri(source : URI, uri : String) : URI return source if uri == '/' new_uri = URI.parse(uri) # return a new uri with source and relative path unless new_uri.scheme && new_uri.host new_uri = source.dup.tap do |u| u.path = (uri[0] == '/') ? uri : "/#{uri}" end end new_uri end # Request data of body struct Data getter body, content_type def initialize(@body : String, @content_type : String? = nil) end end end end ================================================ FILE: src/halite/response.cr ================================================ module Halite class Response def self.new(uri : URI, status_code : Int32, body : String? = nil, headers = HTTP::Headers.new, status_message = nil, body_io : IO? = nil, version = "HTTP/1.1", history = [] of Halite::Response) conn = HTTP::Client::Response.new(status_code, body, headers, status_message, version, body_io) new(uri, conn, history) end getter uri getter conn getter history : Array(Response) def initialize(@uri : URI, @conn : HTTP::Client::Response, @history = [] of Halite::Response) end delegate version, to: @conn delegate status_code, to: @conn delegate status_message, to: @conn delegate content_type, to: @conn delegate success?, to: @conn delegate headers, to: @conn delegate charset, to: @conn delegate body, to: @conn delegate body_io, to: @conn # Content Length def content_length : Int64? if value = @conn.headers["Content-Length"]? value.to_i64 end end # Return a `HTTP::Cookies` of parsed cookie headers or else nil. def cookies : HTTP::Cookies? cookies = @conn.cookies ? @conn.cookies : HTTP::Cookies.from_server_headers(@conn.headers) # Try to fix empty domain cookies.map do |cookie| cookie.domain = @uri.host unless cookie.domain cookie end cookies end # Return a list of parsed link headers proxies or else nil. def links : Hash(String, Halite::HeaderLink)? return unless raw = headers["Link"]? HeaderLink.parse(raw, uri) end def rate_limit : Halite::RateLimit? RateLimit.parse(headers) end # Raise `Halite::ClientError`/`Halite::ServerError` if one occurred. # # - `4XX` raise an `Halite::ClientError` exception # - `5XX` raise an `Halite::ServerError` exception # - return `nil` with other status code # # ``` # Halite.get("https://httpbin.org/status/404").raise_for_status # # => Unhandled exception: 404 not found error with url: https://httpbin.org/status/404 (Halite::ClientError) # # Halite.get("https://httpbin.org/status/500", params: {"foo" => "bar"}).raise_for_status # # => Unhandled exception: 500 internal server error error with url: https://httpbin.org/status/500?foo=bar (Halite::ServerError) # # Halite.get("https://httpbin.org/status/301").raise_for_status # # => nil # ``` def raise_for_status if status_code >= 400 && status_code < 500 raise Halite::ClientError.new(status_code: status_code, uri: uri) elsif status_code >= 500 && status_code < 600 raise Halite::ServerError.new(status_code: status_code, uri: uri) end end # Parse response body with corresponding MIME type adapter. def parse(name : String? = nil) name ||= content_type raise Halite::Error.new("Missing media type") unless name raise Halite::UnRegisterMimeTypeError.new("unregister MIME type adapter: #{name}") unless MimeType[name]? MimeType[name].decode to_s end # Return filename if it exists, else `Nil`. def filename : String? headers["Content-Disposition"]?.try do |value| value.split("filename=")[1] end end # Return raw of response def to_raw io = IO::Memory.new @conn.to_io(io) io end # Return status_code, headers and body in a array def to_a [@conn.status_code, @conn.headers, to_s] end # Return String eagerly consume the entire body as a string def to_s @conn.body? ? @conn.body : @conn.body_io.to_s end def inspect "#<#{self.class} #{version} #{status_code} #{status_message} #{headers.to_flat_h}>" end def to_s(io) io << to_s end end end ================================================ FILE: src/halite.cr ================================================ require "./halite/*" require "./halite/ext/*" module Halite extend Chainable VERSION = "0.12.1" module Helper # Parses a `Time` into a [RFC 3339](https://tools.ietf.org/html/rfc3339) datetime format string # ([ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf) profile). # # > Load Enviroment named "TZ" as high priority def self.to_rfc3339(time : Time, *, timezone = ENV["TZ"]?, fraction_digits : Int = 0) Time::Format::RFC_3339.format(time.in(configure_location(timezone)), fraction_digits: fraction_digits) end # Parses a `Time` into a [RFC 3339](https://tools.ietf.org/html/rfc3339) datetime format string to `IO` # ([ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf) profile). # # > Load Enviroment named "TZ" as high priority def self.to_rfc3339(time : Time, io : IO, *, timezone = ENV["TZ"]?, fraction_digits : Int = 0) Time::Format::RFC_3339.format(time.in(configure_location(timezone)), io, fraction_digits) end # :nodoc: private def self.configure_location(timezone = ENV["TZ"]?) timezone ? Time::Location.load(timezone.not_nil!) : Time::Location::UTC end end @@features = {} of String => Feature.class module FeatureRegister def register_feature(name : String, klass : Feature.class) @@features[name] = klass end def feature(name : String) @@features[name] end def feature?(name : String) @@features[name]? end def has_feature?(name) @@features.keys.includes?(name) end end extend FeatureRegister end