├── .editorconfig ├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── crystal.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── azu.png ├── js └── azu-spark.js ├── load_test.yml ├── mkdocs.yml ├── playground ├── channels │ └── example_channel.cr ├── endpoints │ ├── html_endpoint.cr │ ├── json_endpoint.cr │ ├── load_endpoint.cr │ └── text_endpoint.cr ├── example_app.cr ├── requests │ ├── example_request.cr │ └── json_req.cr ├── responses │ ├── html_response.cr │ ├── json_response.cr │ └── text_response.cr └── templates │ └── example.html ├── shard.yml ├── spec ├── azu │ ├── router_spec.cr │ └── websocket_spec.cr ├── azu_spec.cr └── spec_helper.cr └── src ├── azu.cr └── azu ├── channel.cr ├── component.cr ├── configuration.cr ├── content_negotiator.cr ├── environment.cr ├── error.cr ├── handler ├── cors.cr ├── csrf.cr ├── endpoint.cr ├── ip_spoofing.cr ├── logger.cr ├── request_id.cr ├── rescuer.cr ├── simple_logger.cr ├── static.cr └── throttle.cr ├── http_request.cr ├── log_format.cr ├── markup.cr ├── method.cr ├── params.cr ├── request.cr ├── response.cr ├── router.cr ├── spark.cr ├── templates.cr └── templates └── error.html /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | export CRYSTAL_ENV=development 2 | export CRYSTAL_LOG_SOURCES="*" 3 | export CRYSTAL_LOG_LEVEL=info 4 | export CRYSTAL_WORKERS=8 5 | export PORT=4000 6 | export PORT_REUSE=true 7 | export HOST=0.0.0.0 8 | export SSL_CERT 9 | export SSL_KEY 10 | export SSL_CA 11 | export SSL_MODE -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Crystal CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | container: 13 | image: crystallang/crystal 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Install dependencies 18 | run: shards install 19 | 20 | - name: Build binary to test 21 | run: shards build example_app 22 | 23 | - name: Check code style 24 | run: crystal tool format --check 25 | 26 | - name: Run tests 27 | run: crystal spec 28 | env: 29 | CRYSTAL_ENV: pipeline 30 | CRYSTAL_LOG_SOURCES: "*" 31 | CRYSTAL_LOG_LEVEL: DEBUG 32 | PORT: 4000 33 | PORT_REUSE: false 34 | HOST: 0.0.0.0 35 | 36 | - name: Generate Azu API Docs 37 | run: crystal docs 38 | release: 39 | runs-on: ubuntu-latest 40 | needs: 41 | - build 42 | if: ${{ success() }} 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v2 46 | with: 47 | fetch-depth: 0 48 | 49 | - name: Compute Release Version 50 | id: semver 51 | uses: paulhatch/semantic-version@v4.0.2 52 | with: 53 | tag_prefix: "v" 54 | major_pattern: "(MAJOR)" 55 | minor_pattern: "(MINOR)" 56 | # A string to determine the format of the version output 57 | format: "${major}.${minor}.${patch}" 58 | # If this is set to true, *every* commit will be treated as a new version. 59 | bump_each_commit: false 60 | 61 | - name: Bump Shard Version 62 | id: bump-shard 63 | uses: fjogeleit/yaml-update-action@master 64 | with: 65 | valueFile: shard.yml 66 | propertyPath: version 67 | value: ${{steps.semver.outputs.version}} 68 | commitChange: true 69 | targetBranch: master 70 | masterBranchName: master 71 | createPR: false 72 | branch: master 73 | message: Set shard version ${{ steps.semver.outputs.version }} 74 | 75 | - name: Create Release 76 | id: create_release 77 | uses: actions/create-release@latest 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | tag_name: ${{steps.semver.outputs.version_tag}} 82 | release_name: Release v${{steps.semver.outputs.version}} 83 | draft: false 84 | prerelease: false 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | .vscode/ 5 | *.dwarf 6 | *.log 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in applications that use them 10 | /shard.lock 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Elias J. Perez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Azu 4 | 5 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/b58f03f01de241e0b75f222e31d905d7)](https://www.codacy.com/manual/eliasjpr/azu?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/azu&utm_campaign=Badge_Grade) ![Crystal CI](https://github.com/eliasjpr/azu/workflows/Crystal%20CI/badge.svg?branch=master) 6 | 7 | AZU is a toolkit for artisans with expressive, elegant syntax that offers great performance to build rich, interactive type safe, applications quickly, with less code and conhesive parts that adapts to your prefer style. 8 | 9 | ### Documentation 10 | 11 | [Azu Toolkit Documentation](https://azutopia.gitbook.io/azu/) 12 | 13 | ### Semantics 14 | 15 | * Simplicity: pre-existing knowledge of users to minimise their learning curve when using a module, so anything with high unpredictability factor is a good candidate for re-design 16 | * Least Effort: Everyone tends to follow the path that is as close to effortless as possible. 17 | * Opportunity Cost: To make a good economic decision, we want to choose the option with the greatest benefit to us but the lowest cost. 18 | * Cost Of Delay: Emphasises holding on taking important actions and crucial decisions for as long as possible. 19 | * SOLID The SOLID principles do not only apply on software development but also when architecting a system. 20 | 21 | ## Installation 22 | 23 | 1. Add the dependency to your `shard.yml`: 24 | 25 | ```yaml 26 | dependencies: 27 | azu: 28 | github: azutoolkit/azu 29 | ``` 30 | 31 | 2. Run `shards install` 32 | 33 | ## Contributing 34 | 35 | 1. Fork it () 36 | 2. Create your feature branch (`git checkout -b my-new-feature`) 37 | 3. Commit your changes (`git commit -am 'Add some feature'`) 38 | 4. Push to the branch (`git push origin my-new-feature`) 39 | 5. Create a new Pull Request 40 | 41 | ## Contributors 42 | 43 | - [Elias J. Perez](https://github.com/eliasjpr) - creator and maintainer 44 | -------------------------------------------------------------------------------- /azu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azutoolkit/azu/19c42a7ddaf7f205d8e0ef4062029e44dba979a5/azu.png -------------------------------------------------------------------------------- /js/azu-spark.js: -------------------------------------------------------------------------------- 1 | import { h, render, hydrate } from 'https://unpkg.com/preact?module'; 2 | import htm from 'https://unpkg.com/htm?module'; 3 | 4 | const html = htm.bind(h); 5 | 6 | var url = new URL(location.href); 7 | url.protocol = url.protocol.replace('http', 'ws'); 8 | url.pathname = '/spark'; 9 | var live_view = new WebSocket(url); 10 | 11 | const sparkRenderEvent = new CustomEvent('spark-render'); 12 | 13 | live_view.addEventListener('open', (event) => { 14 | // Hydrate client-side rendering 15 | document.querySelectorAll('[data-spark-view]') 16 | .forEach((view) => { 17 | var node = html(view.innerHTML)[0]; 18 | hydrate(node, view.children[0]); 19 | 20 | live_view.send(JSON.stringify({ 21 | subscribe: view.getAttribute('data-spark-view'), 22 | })) 23 | }); 24 | }); 25 | 26 | live_view.addEventListener('message', (event) => { 27 | var html = htm.bind(h); 28 | var data = event.data; 29 | var { id, content } = JSON.parse(data); 30 | 31 | document.querySelectorAll(`[data-spark-view="${id}"]`) 32 | .forEach((view) => { 33 | var div = window.$('
' + content + '
'); 34 | view.children[0].innerHTML = div[0].innerHTML 35 | render(div[0], view, view.children[0]); 36 | 37 | document.dispatchEvent(sparkRenderEvent); 38 | }); 39 | }); 40 | 41 | live_view.addEventListener('close', (event) => { 42 | // Do we need to do anything here? 43 | }); 44 | 45 | [ 46 | 'click', 47 | 'change', 48 | 'input', 49 | ].forEach((event_type) => { 50 | document.addEventListener(event_type, (event) => { 51 | var element = event.target; 52 | var event_name = element.getAttribute('live-' + event_type); 53 | 54 | if (typeof event_name === 'string') { 55 | var channel = event 56 | .target 57 | .closest('[data-spark-view]') 58 | .getAttribute('data-spark-view') 59 | 60 | var data = {}; 61 | switch (element.type) { 62 | case "checkbox": data = { value: element.checked }; break; 63 | // Are there others? 64 | default: data = { value: element.getAttribute('live-value') || element.value }; break; 65 | } 66 | 67 | live_view.send(JSON.stringify({ 68 | event: event_name, 69 | data: JSON.stringify(data), 70 | channel: channel, 71 | })); 72 | } 73 | }); 74 | }); -------------------------------------------------------------------------------- /load_test.yml: -------------------------------------------------------------------------------- 1 | execution: 2 | - concurrency: 500 3 | hold-for: 30s 4 | ramp-up: 1s 5 | throughput: 30000 6 | steps: 1 7 | scenario: sample 8 | 9 | scenarios: 10 | sample: 11 | headers: 12 | Accept: text/plain 13 | requests: 14 | - http://localhost:4000/test/hello?name=Elias 15 | 16 | reporting: 17 | - module: final-stats 18 | - module: console 19 | 20 | modules: 21 | jmeter: 22 | path: ./local/jmeter 23 | properties: 24 | log_level: DEBUG 25 | console: 26 | disable: false 27 | 28 | settings: 29 | check-interval: 1s 30 | default-executor: jmeter 31 | 32 | provisioning: local 33 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Azu Toolkit 2 | site_url: https://eliasjpr.github.io/azu/ 3 | repo_url: https://github.com/eliasjpr/azu 4 | edit_uri: blob/master/docs/ 5 | 6 | theme: 7 | name: material 8 | palette: 9 | # Toggle dark mode 10 | - scheme: slate 11 | primary: blue-gray 12 | accent: blue 13 | icon: 14 | repo: fontawesome/brands/github 15 | features: 16 | - navigation.instant 17 | - navigation.tabs 18 | - navigation.tabs.sticky 19 | - navigation.sections 20 | - search.suggest 21 | - search.highlight 22 | - search.share 23 | 24 | extra_css: 25 | - css/mkdocstrings.css 26 | 27 | plugins: 28 | - search 29 | - gen-files: 30 | scripts: 31 | - docs/gen_doc_stubs.py 32 | - mkdocstrings: 33 | default_handler: crystal 34 | watch: [src] 35 | 36 | markdown_extensions: 37 | - pymdownx.highlight 38 | - pymdownx.magiclink 39 | - pymdownx.saneheaders 40 | - pymdownx.superfences 41 | - deduplicate-toc 42 | -------------------------------------------------------------------------------- /playground/channels/example_channel.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | # Websockets Channels 3 | class ExampleChannel < Azu::Channel 4 | SUBSCRIBERS = [] of HTTP::WebSocket 5 | 6 | ws "/hi" 7 | 8 | def on_connect 9 | SUBSCRIBERS << socket.not_nil! 10 | @socket.not_nil!.send SUBSCRIBERS.size.to_s 11 | end 12 | 13 | def on_binary(binary) 14 | end 15 | 16 | def on_pong(message) 17 | end 18 | 19 | def on_ping(message) 20 | end 21 | 22 | def on_message(message) 23 | SUBSCRIBERS.each { |s| s.send "Polo!" } 24 | end 25 | 26 | def on_close(code : HTTP::WebSocket::CloseCode | Int? = nil, message = nil) 27 | SUBSCRIBERS.delete socket 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /playground/endpoints/html_endpoint.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct HtmlEndpoint 3 | include Endpoint(ExampleReq, HtmlPage) 4 | 5 | get "/html/:name" 6 | get "/html" 7 | 8 | def call : HtmlPage 9 | status 200 10 | content_type "text/html" 11 | header "custom", "Fake custom header" 12 | HtmlPage.new example_req.name 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /playground/endpoints/json_endpoint.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct JsonEndpoint 3 | include Endpoint(JsonReq, JsonResponse) 4 | post "/json/:id" 5 | 6 | def call : JsonResponse 7 | status 200 8 | content_type "application/json" 9 | raise error("Invalid JSON", 400, json_req.error_messages) unless json_req.valid? 10 | JsonResponse.new json_req 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /playground/endpoints/load_endpoint.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct LoadEndpoint 3 | include Endpoint(ExampleReq, HtmlPage) 4 | get "/load/:name" 5 | 6 | def call : HtmlPage 7 | status 201 8 | content_type "text/html" 9 | HtmlPage.new example_req.name 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /playground/endpoints/text_endpoint.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct TextEndpoint 3 | include Endpoint(ExampleReq, TextResponse) 4 | 5 | get "/text/" 6 | 7 | @hello_world = TextResponse.new 8 | 9 | def call : TextResponse 10 | content_type "text/plain" 11 | status 201 12 | @hello_world 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /playground/example_app.cr: -------------------------------------------------------------------------------- 1 | require "../src/azu" 2 | 3 | module ExampleApp 4 | include Azu 5 | configure do 6 | templates.path = "playground/templates" 7 | end 8 | end 9 | 10 | require "./requests/*" 11 | require "./responses/*" 12 | require "./endpoints/*" 13 | require "./channels/*" 14 | 15 | ExampleApp.start [ 16 | Azu::Handler::Rescuer.new, 17 | Azu::Handler::Logger.new, 18 | ] 19 | -------------------------------------------------------------------------------- /playground/requests/example_request.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct ExampleReq 3 | include Azu::Request 4 | 5 | BAD_REQUEST = "Error validating request" 6 | getter name : String 7 | 8 | validate name, presence: true, message: "Name param must be present!" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /playground/requests/json_req.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module ExampleApp 4 | struct JsonReq 5 | include Request 6 | 7 | getter id : Int64? = nil 8 | getter users : Array(String) 9 | getter config : Config 10 | 11 | struct Config 12 | include Request 13 | property? allowed : Bool = false 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /playground/responses/html_response.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct HtmlPage 3 | include Response 4 | include Templates::Renderable 5 | 6 | def initialize(@name : String) 7 | end 8 | 9 | def render 10 | view "example.html", {name: @name} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /playground/responses/json_response.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct JsonResponse 3 | include JSON::Serializable 4 | include Response 5 | 6 | def initialize(@request : JsonReq) 7 | end 8 | 9 | def render 10 | @request.to_json 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /playground/responses/text_response.cr: -------------------------------------------------------------------------------- 1 | module ExampleApp 2 | struct TextResponse 3 | include Response 4 | 5 | def render 6 | "Hello World!" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /playground/templates/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Page 6 | 7 | 8 |

Welcome, {{name}}

9 | 10 | 11 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: azu 2 | version: 0.4.14 3 | authors: 4 | - Elias J. Perez 5 | crystal: '>= 0.35.0' 6 | license: MIT 7 | targets: 8 | example_app: 9 | main: playground/example_app.cr 10 | dependencies: 11 | radix: 12 | github: luislavena/radix 13 | exception_page: 14 | github: crystal-loot/exception_page 15 | schema: 16 | github: azutoolkit/schema 17 | crinja: 18 | github: straight-shoota/crinja 19 | -------------------------------------------------------------------------------- /spec/azu/router_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | class ExampleEndpoint 4 | include Azu::Endpoint(Azu::Request, Azu::Response) 5 | 6 | def call : Azu::Response 7 | end 8 | end 9 | 10 | describe Azu::Router do 11 | router = Azu::Router.new 12 | path = "/example_router" 13 | 14 | it "adds endpoint" do 15 | router.add "/", ExampleEndpoint.new, Azu::Method::Get 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/azu/websocket_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Azu::Channel do 4 | pending "sends socket message" do 5 | result = nil 6 | socket = HTTP::WebSocket.new URI.parse("ws://localhost:4000/hi") 7 | socket.on_message { |msg| result = msg } 8 | 9 | spawn socket.run 10 | socket.send "Marco!" 11 | sleep 40.milliseconds 12 | 13 | result.should eq "Polo!" 14 | end 15 | 16 | pending "removes subscriber on disconnect" do 17 | result = nil 18 | socket = HTTP::WebSocket.new URI.parse("ws://localhost:4000/hi") 19 | HTTP::WebSocket.new URI.parse("ws://localhost:4000/hi") 20 | socket.on_message { |msg| result = msg } 21 | 22 | spawn socket.run 23 | sleep 40.milliseconds 24 | 25 | result.should eq "2" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/azu_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper.cr" 2 | require "http/client" 3 | 4 | describe Azu do 5 | client = HTTP::Client.new "localhost", 4000 6 | 7 | describe "coverting http request body to objects" do 8 | it "returns request as json" do 9 | payload = {id: 1, users: ["John", "Paul"], config: {"allowed" => true}} 10 | headers = HTTP::Headers{"Accept" => "application/json", "Content-Type" => "application/json"} 11 | 12 | response = client.post "/json/1", headers: headers, body: payload.to_json 13 | data = JSON.parse(response.body) 14 | 15 | response.status_code.should eq 200 16 | data["id"].should eq payload[:id] 17 | data["users"].should eq payload[:users] 18 | data["config"].should eq payload[:config] 19 | end 20 | end 21 | 22 | describe "Http Errors" do 23 | it "returns request not found" do 24 | response = client.get "/invalid_path", headers: HTTP::Headers{"Accept" => "text/plain"} 25 | response.status_code.should eq 404 26 | response.body.should contain "Source: /invalid_path" 27 | end 28 | 29 | it "displays hello world" do 30 | response = client.get "/html/world", headers: HTTP::Headers{"Accept" => "text/plain"} 31 | response.status_code.should eq 200 32 | response.body.should contain %q(Welcome, world) 33 | end 34 | end 35 | 36 | describe "Render HTML" do 37 | it "returns valid html" do 38 | name = "santa" 39 | response = client.get "/html/#{name}", headers: HTTP::Headers{"Accept" => "text/plain"} 40 | response.status_code.should eq 200 41 | response.body.should contain %(Welcome, #{name}) 42 | end 43 | end 44 | 45 | describe "Http headers" do 46 | path = "/html?name=Elias" 47 | response = client.get path, headers: HTTP::Headers{"Accept" => "text/plain"} 48 | 49 | it "can set headers" do 50 | response.headers["Custom"].should contain %q(Fake custom header) 51 | end 52 | 53 | it "sets status code" do 54 | response.status_code.should eq 200 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/azu" 3 | 4 | ENV["CRYSTAL_ENV"] ||= "test" 5 | 6 | process = Process.new("./bin/example_app") 7 | # Wait for process to start 8 | sleep 1.seconds 9 | 10 | Spec.after_suite do 11 | process.not_nil!.signal Signal::KILL 12 | end 13 | -------------------------------------------------------------------------------- /src/azu.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "log" 3 | require "radix" 4 | require "json" 5 | require "xml" 6 | require "colorize" 7 | require "schema" 8 | require "crinja" 9 | require "./azu/router" 10 | require "./azu/**" 11 | 12 | module Azu 13 | CONFIG = Configuration.new 14 | 15 | macro included 16 | def self.configure 17 | with CONFIG yield CONFIG 18 | end 19 | 20 | def self.log 21 | CONFIG.log 22 | end 23 | 24 | def self.env 25 | CONFIG.env 26 | end 27 | 28 | def self.config 29 | CONFIG 30 | end 31 | 32 | def self.start(handlers : Array(HTTP::Handler)) 33 | server = if handlers.empty? 34 | HTTP::Server.new { |context| config.router.process(context) } 35 | else 36 | HTTP::Server.new(handlers) { |context| config.router.process(context) } 37 | end 38 | 39 | if config.tls? 40 | server.bind_tls config.host, config.port, config.tls, config.port_reuse 41 | else 42 | server.bind_tcp config.host, config.port, config.port_reuse 43 | end 44 | 45 | Signal::INT.trap do 46 | Signal::INT.reset 47 | log.info { "Shutting down server" } 48 | server.close 49 | end 50 | 51 | loop do 52 | begin 53 | log.info { server_info } 54 | server.listen 55 | break 56 | rescue e 57 | if e == Errno 58 | log.info(exception: e) { "Restarting server..." } 59 | else 60 | log.error(exception: e) { "Server failed to start!" } 61 | break 62 | end 63 | end 64 | end 65 | end 66 | 67 | private def self.server_info(time = Time.monotonic) 68 | String.build do |s| 69 | s << "Server started at #{Time.local.to_s("%a %m/%d/%Y %I:%M:%S")}.".colorize(:white).underline 70 | s << "\n ⤑ Environment: ".colorize(:white) 71 | s << env.colorize(:light_blue) 72 | s << "\n ⤑ Host: ".colorize(:white) 73 | s << config.host.colorize(:light_blue) 74 | s << "\n ⤑ Port: ".colorize(:white) 75 | s << config.port.colorize(:light_blue) 76 | s << "\n ⤑ Startup Time: ".colorize(:white) 77 | s << (Time.monotonic - time).total_milliseconds 78 | s << " millis".colorize(:white) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/azu/channel.cr: -------------------------------------------------------------------------------- 1 | require "http/web_socket" 2 | 3 | module Azu 4 | # A channel encapsulates a logical unit of work similar to an Endpoint. 5 | # 6 | # Channels manage WebSocket connections, handling multiple instances where a single client 7 | # may have multiple WebSocket connections open to the application. 8 | # 9 | # Each channel can broadcast to multiple connected clients. 10 | # 11 | # To set up a WebSocket route in your routing service: 12 | # 13 | # ``` 14 | # ExampleApp.router do 15 | # ws "/hi", ExampleApp::ExampleChannel 16 | # end 17 | # ``` 18 | # 19 | # ## Pings and Pongs: The Heartbeat of WebSockets 20 | # 21 | # After the handshake, either the client or the server can send a ping to the other party. 22 | # Upon receiving a ping, the recipient must promptly send back a pong. This mechanism ensures 23 | # that the client remains connected. 24 | abstract class Channel 25 | getter! socket : HTTP::WebSocket 26 | 27 | def initialize(@socket : HTTP::WebSocket) 28 | end 29 | 30 | # Registers a WebSocket route 31 | def self.ws(path : Router::Path) 32 | CONFIG.router.ws(path, self) 33 | end 34 | 35 | # Invoked when a connection is established 36 | abstract def on_connect 37 | 38 | # Invoked when a text message is received 39 | abstract def on_message(message : String) 40 | 41 | # Invoked when a binary message is received 42 | abstract def on_binary(binary : Bytes) 43 | 44 | # Invoked when a ping frame is received 45 | abstract def on_ping(message : String) 46 | 47 | # Invoked when a pong frame is received 48 | abstract def on_pong(message : String) 49 | 50 | # Invoked when the connection is closed 51 | abstract def on_close(code : HTTP::WebSocket::CloseCode?, message : String?) 52 | 53 | # Handles the incoming WebSocket HTTP request 54 | def call(context : HTTP::Server::Context) 55 | on_connect 56 | 57 | socket.on_message { |message| on_message(message) } 58 | socket.on_binary { |binary| on_binary(binary) } 59 | socket.on_ping { |message| on_ping(message) } 60 | socket.on_pong { |message| on_pong(message) } 61 | socket.on_close { |code, message| on_close(code, message) } 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/azu/component.cr: -------------------------------------------------------------------------------- 1 | require "./markup" 2 | require "uuid" 3 | 4 | module Azu 5 | module Component 6 | include Markup 7 | property? mounted = false 8 | property? connected = false 9 | getter id : String = UUID.random.to_s 10 | 11 | @socket : HTTP::WebSocket? = nil 12 | @created_at = Time.utc 13 | 14 | macro included 15 | def self.mount(**args) 16 | component = new **args 17 | component.mounted = true 18 | Azu::Spark::COMPONENTS[component.id] = component 19 | component.render 20 | end 21 | end 22 | 23 | def dicconnected? 24 | !connected? 25 | end 26 | 27 | def age 28 | Time.utc - @created_at 29 | end 30 | 31 | def mount 32 | end 33 | 34 | def unmount 35 | end 36 | 37 | def on_event(name, data) 38 | end 39 | 40 | def content 41 | end 42 | 43 | def refresh 44 | content 45 | json = {content: to_s, id: id}.to_json 46 | @socket.not_nil!.send json 47 | ensure 48 | @view = IO::Memory.new 49 | end 50 | 51 | def refresh 52 | yield 53 | refresh 54 | end 55 | 56 | def every(duration : Time::Span, &block) 57 | spawn do 58 | while connected? 59 | sleep duration 60 | block.call if connected? 61 | end 62 | rescue IO::Error 63 | # This happens when a socket closes at just the right time 64 | rescue ex 65 | ex.inspect STDERR 66 | end 67 | end 68 | 69 | def socket=(other) 70 | @socket = other 71 | end 72 | 73 | def render 74 | content 75 | <<-HTML 76 |
77 |
#{to_s}
78 |
79 | HTML 80 | 81 | ensure 82 | @view = IO::Memory.new 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /src/azu/configuration.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "openssl" 3 | 4 | module Azu 5 | # Holds all the configuration properties for your Azu Application 6 | # 7 | # Azu expects configurations to be loaded from environment variables 8 | # for local development it is recommended to use `.env` files to store 9 | # your development configuration properties. 10 | # 11 | # 12 | # ``` 13 | # Azu.configure do |c| 14 | # c.port = 4000 15 | # c.host = localhost 16 | # c.port_reuse = true 17 | # c.log = Log.for("My Awesome App") 18 | # c.env = Environment::Development 19 | # c.template.path = "./templates" 20 | # c.template.error_path = "./error_template" 21 | # end 22 | # ``` 23 | class Configuration 24 | private TEMPLATES_PATH = "../../templates" 25 | private ERROR_TEMPLATE = "./src/azu/templates" 26 | 27 | Log.setup(:debug, Log::IOBackend.new(formatter: LogFormat)) 28 | property log : Log = Log.for("Azu") 29 | 30 | property port : Int32 = ENV.fetch("PORT", "4000").to_i 31 | property port_reuse : Bool = ENV.fetch("PORT_REUSE", "true") == "true" 32 | property host : String = ENV.fetch("HOST", "0.0.0.0") 33 | property env : Environment = Environment.parse(ENV.fetch("CRYSTAL_ENV", "development")) 34 | 35 | property ssl_cert : String = ENV.fetch("SSL_CERT", "") 36 | property ssl_key : String = ENV.fetch("SSL_KEY", "") 37 | property ssl_ca : String = ENV.fetch("SSL_CA", "") 38 | property ssl_mode : String = ENV.fetch("SSL_MODE", "none") 39 | 40 | getter router : Router = Router.new 41 | getter templates : Templates = Templates.new( 42 | ENV.fetch("TEMPLATES_PATH", Path[TEMPLATES_PATH].expand.to_s).split(","), 43 | ENV.fetch("ERROR_TEMPLATE", Path[ERROR_TEMPLATE].expand.to_s) 44 | ) 45 | 46 | def tls 47 | OpenSSL::SSL::Context::Server.from_hash({ 48 | "key" => ssl_key, 49 | "cert" => ssl_cert, 50 | "ca" => ssl_ca, 51 | "verify_mode" => ssl_mode, 52 | }) 53 | end 54 | 55 | def tls? 56 | !ssl_cert.empty? && !ssl_key.empty? 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/azu/content_negotiator.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # :nodoc: 3 | module ContentNegotiator 4 | def self.content_type(context) 5 | return if context.response.headers["content_type"]? 6 | 7 | if accept = context.request.accept 8 | accept.each do |a| 9 | case a.sub_type.not_nil! 10 | when .includes?("html") 11 | context.response.content_type = a.to_s 12 | break 13 | when .includes?("json") 14 | context.response.content_type = a.to_s 15 | break 16 | when .includes?("xml") 17 | context.response.content_type = a.to_s 18 | break 19 | when .includes?("plain"), "*" 20 | context.response.content_type = a.to_s 21 | break 22 | else 23 | context.response.content_type = a.to_s 24 | break 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/azu/environment.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # Allows to test which environment Azu is running in. 3 | # 4 | # The current application environment is determined via the CRYSTAL_ENV variable from your .env file. 5 | enum Environment 6 | # Build environment ideal for building images and compiling 7 | Build 8 | # Development environment normally developer local development computer 9 | Development 10 | # Test environment for running unit tests and component integration tests 11 | Test 12 | # Integration environment for running integration tests across network 13 | Integration 14 | # Acceptance/System test environment to evaluate the system's compliance with the business requirements and assess whether it is acceptable for delivery 15 | Acceptance 16 | # For running in a pipeline environment 17 | Pipeline 18 | # Staging environment nearly exact replica of a production environment for software testing 19 | Staging 20 | # where software and other products are actually put into operation for their intended uses by end users 21 | Production 22 | 23 | # Checks if the current environment is in any of the environment listed 24 | def in?(environments : Array(Symbol)) 25 | environments.any? { |name| self == name } 26 | end 27 | 28 | # Checks if the current environment matches another environment 29 | def in?(*environments : Environment) 30 | in?(environments.to_a) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/azu/error.cr: -------------------------------------------------------------------------------- 1 | require "ecr" 2 | require "exception_page" 3 | 4 | module Azu 5 | # :nodoc: 6 | class ExceptionPage < ::ExceptionPage 7 | def styles : ExceptionPage::Styles 8 | ::ExceptionPage::Styles.new( 9 | accent: "red", 10 | ) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/azu/handler/cors.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | module Handler 3 | module Headers 4 | VARY = "Vary" 5 | ORIGIN = "Origin" 6 | X_ORIGIN = "X-Origin" 7 | REQUEST_METHOD = "Access-Control-Request-Method" 8 | REQUEST_HEADERS = "Access-Control-Request-Headers" 9 | ALLOW_EXPOSE = "Access-Control-Expose-Headers" 10 | ALLOW_ORIGIN = "Access-Control-Allow-Origin" 11 | ALLOW_METHODS = "Access-Control-Allow-Methods" 12 | ALLOW_HEADERS = "Access-Control-Allow-Headers" 13 | ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials" 14 | ALLOW_MAX_AGE = "Access-Control-Max-Age" 15 | end 16 | 17 | class CORS 18 | alias OriginType = Array(String | Regex) 19 | FORBIDDEN = "Forbidden for invalid origins, methods or headers" 20 | ALLOW_METHODS = %w(POST PUT PATCH DELETE) 21 | ALLOW_HEADERS = %w(Accept Content-Type) 22 | 23 | property origins, headers, methods, credentials, max_age 24 | @origin : Origin 25 | 26 | def initialize( 27 | @origins : OriginType = ["*", %r()], 28 | @methods = ALLOW_METHODS, 29 | @headers = ALLOW_HEADERS, 30 | @credentials = false, 31 | @max_age : Int32? = 0, 32 | @expose_headers : Array(String)? = nil, 33 | @vary : String? = nil 34 | ) 35 | @origin = Origin.new(origins) 36 | end 37 | 38 | def call(context : HTTP::Server::Context) 39 | return call_next(context) unless @origin.origin_header?(context.request) 40 | 41 | if @origin.match?(context.request) 42 | is_preflight_request = preflight?(context) 43 | put_expose_header(context.response) 44 | Preflight.process(context, self) if is_preflight_request 45 | put_response_headers(context.response) 46 | call_next(context) unless is_preflight_request 47 | else 48 | forbidden(context) 49 | end 50 | end 51 | 52 | def forbidden(context) 53 | context.response.headers["Content-Type"] = "text/plain" 54 | context.response.respond_with_status 403 55 | end 56 | 57 | private def put_expose_header(response) 58 | response.headers[Headers::ALLOW_EXPOSE] = @expose_headers.as(Array).join(",") if @expose_headers 59 | end 60 | 61 | private def put_response_headers(response) 62 | response.headers[Headers::ALLOW_CREDENTIALS] = @credentials.to_s if @credentials 63 | response.headers[Headers::ALLOW_ORIGIN] = @origin.request_origin.not_nil! 64 | response.headers[Headers::VARY] = vary unless @origin.any? 65 | end 66 | 67 | private def vary 68 | String.build do |str| 69 | str << Headers::ORIGIN 70 | str << "," << @vary if @vary 71 | end 72 | end 73 | 74 | private def preflight?(context) 75 | context.request.method == "OPTIONS" 76 | end 77 | end 78 | 79 | module Preflight 80 | extend self 81 | 82 | def process(context, cors) 83 | return cors.forbidden(context) unless valid?(context, cors) 84 | put_preflight_headers(context.request, context.response, cors.max_age) 85 | end 86 | 87 | def valid?(context, cors) 88 | valid_method?(context.request, cors.methods) && 89 | valid_headers?(context.request, cors.headers) 90 | end 91 | 92 | def put_preflight_headers(request, response, max_age) 93 | response.headers[Headers::ALLOW_METHODS] = request.headers[Headers::REQUEST_METHOD] 94 | response.headers[Headers::ALLOW_HEADERS] = request.headers[Headers::REQUEST_HEADERS] 95 | response.headers[Headers::ALLOW_MAX_AGE] = max_age.to_s if max_age 96 | response.content_length = 0 97 | response.flush 98 | end 99 | 100 | def valid_method?(request, methods) 101 | methods.includes? request.headers[Headers::REQUEST_METHOD]? 102 | end 103 | 104 | def valid_headers?(request, headers) 105 | request_headers = request.headers[Headers::REQUEST_HEADERS]? 106 | return false if request_headers.nil? || request_headers.empty? 107 | 108 | headers.any? do |header| 109 | request_headers.downcase.split(',').includes? header.downcase 110 | end 111 | end 112 | end 113 | 114 | struct Origin 115 | getter request_origin : String? 116 | 117 | def initialize(@origins : CORS::OriginType) 118 | end 119 | 120 | def match?(request) 121 | return false if @origins.empty? 122 | return false unless origin_header?(request) 123 | return true if any? 124 | 125 | @origins.any? do |origin| 126 | case origin 127 | when String then origin == request_origin 128 | when Regex then origin =~ request_origin 129 | end 130 | end 131 | end 132 | 133 | def any? 134 | @origins.includes? "*" 135 | end 136 | 137 | protected def origin_header?(request) 138 | @request_origin = request.headers[Headers::ORIGIN]? || request.headers[Headers::X_ORIGIN]? 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /src/azu/handler/csrf.cr: -------------------------------------------------------------------------------- 1 | require "random/secure" 2 | require "crypto/subtle" 3 | 4 | module Azu 5 | module Handler 6 | # The CSRF Handler adds support for Cross Site Request Forgery. 7 | class CSRF 8 | CHECK_METHODS = %w(PUT POST PATCH DELETE) 9 | HEADER_KEY = "X-CSRF-TOKEN" 10 | PARAM_KEY = "_csrf" 11 | CSRF_KEY = "csrf.token" 12 | TOKEN_LENGTH = 32 13 | 14 | class_property token_strategy : PersistentToken | RefreshableToken = PersistentToken 15 | 16 | def initialize(@cookie_provider) 17 | end 18 | 19 | def call(context : HTTP::Server::Context) 20 | if valid_http_method?(context) || self.class.token_strategy.valid_token?(context) 21 | call_next(context) 22 | else 23 | raise Azu::Response::Forbidden.new("CSRF check failed.") 24 | end 25 | end 26 | 27 | def valid_http_method?(context) 28 | !CHECK_METHODS.includes?(context.request.method) 29 | end 30 | 31 | def self.token(context) 32 | token_strategy.token(context) 33 | end 34 | 35 | def self.tag(context) 36 | %Q() 37 | end 38 | 39 | def self.metatag(context) 40 | %Q() 41 | end 42 | 43 | module BaseToken 44 | def request_token(context) 45 | context.params[PARAM_KEY]? || context.request.headers[HEADER_KEY]? 46 | end 47 | 48 | def real_session_token(context) : String 49 | (cookie_provider[CSRF_KEY] ||= Random::Secure.urlsafe_base64(TOKEN_LENGTH)).to_s 50 | end 51 | end 52 | 53 | module RefreshableToken 54 | extend self 55 | extend BaseToken 56 | 57 | def token(context) : String 58 | real_session_token(context) 59 | end 60 | 61 | def valid_token?(context) 62 | (request_token(context) == token(context)) && cookie_provider.delete(CSRF_KEY) 63 | end 64 | end 65 | 66 | module PersistentToken 67 | extend self 68 | extend BaseToken 69 | 70 | def valid_token?(context) 71 | if request_token(context) && real_session_token(context) 72 | decoded_request = Base64.decode(request_token(context).to_s) 73 | return false unless decoded_request.size == TOKEN_LENGTH * 2 74 | 75 | unmasked = TokenOperations.unmask(decoded_request) 76 | session_token = Base64.decode(real_session_token(context)) 77 | return Crypto::Subtle.constant_time_compare(unmasked, session_token) 78 | end 79 | false 80 | rescue Base64::Error 81 | false 82 | end 83 | 84 | def token(context) : String 85 | unmask_token = Base64.decode(real_session_token(context)) 86 | TokenOperations.mask(unmask_token) 87 | end 88 | 89 | module TokenOperations 90 | extend self 91 | 92 | # Creates a masked version of the authenticity token that varies 93 | # on each request. The masking is used to mitigate SSL attacks 94 | # like BREACH. 95 | def mask(unmasked_token : Bytes) : String 96 | one_time_pad = Bytes.new(TOKEN_LENGTH).tap { |buf| Random::Secure.random_bytes(buf) } 97 | encrypted_csrf_token = xor_bytes_arrays(unmasked_token, one_time_pad) 98 | 99 | masked_token = IO::Memory.new 100 | masked_token.write(one_time_pad) 101 | masked_token.write(encrypted_csrf_token) 102 | Base64.urlsafe_encode(masked_token.to_slice) 103 | end 104 | 105 | def unmask(masked_token : Bytes) : Bytes 106 | one_time_pad = masked_token[0, TOKEN_LENGTH] 107 | encrypted_csrf_token = masked_token[TOKEN_LENGTH, TOKEN_LENGTH] 108 | xor_bytes_arrays(encrypted_csrf_token, one_time_pad) 109 | end 110 | 111 | def xor_bytes_arrays(token : Bytes, pad : Bytes) : Bytes 112 | token.map_with_index { |b, i| b ^ pad[i] } 113 | end 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /src/azu/handler/endpoint.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # An Endpoint is an endpoint that handles incoming HTTP requests for a specific route. 3 | # In a Azu application, an endpoint is a simple testable object. 4 | # 5 | # This design provides self contained actions that don’t share their context 6 | # accidentally with other actions. It also prevents gigantic controllers. 7 | # It has several advantages in terms of testability and control of an endpoint. 8 | # 9 | # ``` 10 | # module ExampleApp 11 | # class UserEndpoint 12 | # include Azu::Endpoint(UserRequest, UserResponse) 13 | # end 14 | # end 15 | # ``` 16 | # 17 | module Endpoint(Request, Response) 18 | include HTTP::Handler 19 | 20 | @context : HTTP::Server::Context? = nil 21 | @parmas : Params(Request)? = nil 22 | @request_object : Request? = nil 23 | 24 | # When we include Endpoint module, we make our object compliant with Azu 25 | # Endpoints by implementing the #call, which is a method that accepts no 26 | # arguments 27 | # 28 | # ``` 29 | # def call : IndexPage 30 | # IndexPage.new 31 | # end 32 | # ``` 33 | abstract def call : Response 34 | 35 | # :nodoc: 36 | def call(context : HTTP::Server::Context) 37 | @context = context 38 | @params = Params(Request).new(@context.not_nil!.request) 39 | context.response << call.render 40 | context 41 | end 42 | 43 | macro included 44 | @@resource : String = "" 45 | 46 | def self.path(**params) 47 | url = @@resource 48 | params.each { |k, v| url = url.gsub(/\:#{k}/, v) } 49 | url 50 | end 51 | 52 | {% for method in Azu::Router::RESOURCES %} 53 | def self.{{method.id}}(path : Azu::Router::Path) 54 | @@resource = path 55 | Azu::CONFIG.router.{{method.id}} path, self.new 56 | end 57 | {% end %} 58 | 59 | # Registers crinja path helper filters 60 | {% 61 | resource_name = @type.stringify.split("::") 62 | resource_name = resource_name.size > 1 ? resource_name[-2..-1].join("_") : resource_name.last 63 | resource_name = resource_name.underscore.gsub(/\_endpoint/, "").id 64 | %} 65 | Azu::CONFIG.templates.crinja.filters[:{{resource_name}}_path] = Crinja.filter({id: nil}) do 66 | {{@type.name.id}}.path(id: arguments["id"]) 67 | end 68 | 69 | def self.helper_path_name 70 | :{{resource_name}}_path 71 | end 72 | 73 | {% request_name = Request.stringify.split("::").last.underscore.downcase.id %} 74 | 75 | def {{request_name}} : Request 76 | if json = params.json 77 | Request.from_json json 78 | else 79 | Request.from_query params.to_query 80 | end 81 | end 82 | 83 | def {{request_name}}_contract : Request 84 | if json = params.json 85 | Request.from_json json 86 | else 87 | Request.from_query params.to_query 88 | end 89 | end 90 | end 91 | 92 | # Sets the content type for a response 93 | private def content_type(type : String) 94 | context.response.content_type = type 95 | end 96 | 97 | # Gets requests parameters 98 | private def params : Params 99 | @params.not_nil! 100 | end 101 | 102 | # Gets the request `raw` context 103 | private def context : HTTP::Server::Context 104 | @context.not_nil! 105 | end 106 | 107 | # Gets the request http method 108 | private def method 109 | Method.parse(context.request.method) 110 | end 111 | 112 | # Gets the HTTP headers for a request 113 | private def header 114 | context.request.headers 115 | end 116 | 117 | # Gets the request body as json when accepts equals to `application/json` 118 | private def json 119 | JSON.parse(body.to_s) 120 | end 121 | 122 | # Gets the http request cookies 123 | private def cookies 124 | context.request.cookies 125 | end 126 | 127 | # Sets response headers 128 | private def header(key : String, value : String) 129 | context.response.headers[key] = value 130 | end 131 | 132 | # Sets redirect header 133 | private def redirect(to location : String, status : Int32 = 301) 134 | status status 135 | header "Location", location 136 | Azu::Response::Empty.new 137 | end 138 | 139 | # Adds http cookie to the response 140 | private def cookies(cookie : HTTP::Cookie) 141 | context.response.cookies << cookie 142 | end 143 | 144 | # Sets http staus to the response 145 | private def status(status : Int32) 146 | context.response.status_code = status 147 | end 148 | 149 | # Defines a an Azu error response 150 | private def error(message : String, status : Int32 = 400, errors = [] of String) 151 | Azu::Response::Error.new(message, HTTP::Status.new(status), errors) 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /src/azu/handler/ip_spoofing.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | module Handler 3 | # IP spoofing is the creation of Internet Protocol (IP) packets which have a 4 | # modified source address in order to either hide the identity of the sender, 5 | # to impersonate another computer system, or both. It is a technique often used 6 | # by bad actors to invoke DDoS attacks against a target device or the surrounding infrastructure. 7 | # 8 | # ### Usage 9 | # 10 | # ``` 11 | # Azu::Throttle.new 12 | # ``` 13 | # 14 | class IpSpoofing 15 | include HTTP::Handler 16 | FORWARDED_FOR = "X-Forwarded-For" 17 | CLIENT_IP = "X-Client-IP" 18 | REAL_IP = "X-Real-IP" 19 | 20 | def call(context : HTTP::Server::Context) 21 | headers = context.request.headers 22 | 23 | return call_next(context) unless headers.has_key?(FORWARDED_FOR) 24 | 25 | ips = headers[FORWARDED_FOR].split(/\s*,\s*/) 26 | 27 | return forbidden(context) if headers.has_key?(CLIENT_IP) && !ips.includes?(headers[CLIENT_IP]) 28 | return forbidden(context) if headers.has_key?(REAL_IP) && !ips.includes?(headers[REAL_IP]) 29 | 30 | call_next(context) 31 | end 32 | 33 | private def forbidden(context) 34 | context.response.headers["Content-Type"] = "text/plain" 35 | context.response.headers["Content-Length"] = "0" 36 | context.response.status_code = HTTP::Status::FORBIDDEN.value 37 | context.response.close 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/azu/handler/logger.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | 3 | module Azu 4 | module Handler 5 | class Logger 6 | include HTTP::Handler 7 | 8 | getter log : ::Log 9 | 10 | def initialize(@log : ::Log = CONFIG.log) 11 | end 12 | 13 | def call(context : HTTP::Server::Context) 14 | start = Time.monotonic 15 | 16 | begin 17 | call_next(context) 18 | ensure 19 | elapsed = Time.monotonic - start 20 | elapsed_text = elapsed(elapsed) 21 | 22 | req = context.request 23 | res = context.response 24 | 25 | addr = 26 | case remote_address = req.remote_address 27 | when nil 28 | "-" 29 | when Socket::IPAddress 30 | remote_address.address 31 | else 32 | remote_address 33 | end 34 | 35 | @log.info { message(addr, req, res, elapsed_text) } 36 | end 37 | end 38 | 39 | private def message(addr, req, res, elapsed_text) 40 | String.build do |str| 41 | str << addr.colorize(:green).underline 42 | str << " ⤑ " 43 | str << req.method.colorize(:yellow) 44 | str << entry(:Path, req.resource, :light_blue) 45 | str << status(:Status, res.status_code) 46 | str << entry(:Latency, elapsed_text, :green) 47 | end 48 | end 49 | 50 | private def elapsed(elapsed) 51 | millis = elapsed.total_milliseconds 52 | return "#{millis.round(2)}ms" if millis >= 1 53 | "#{(millis * 1000).round(2)}µs" 54 | end 55 | 56 | private def entry(key, message, color) 57 | String.build do |str| 58 | str << " #{key}: " 59 | str << message.colorize(color) 60 | end 61 | end 62 | 63 | private def status(key, message) 64 | String.build do |str| 65 | str << " #{key}: " 66 | str << http_status(message) 67 | end 68 | end 69 | 70 | private def http_status(status) 71 | case status 72 | when 200..299 then status.colorize(:green) 73 | when 300..399 then status.colorize(:blue) 74 | when 400..499 then status.colorize(:yellow) 75 | when 500..599 then status.colorize(:red) 76 | else status.colorize(:white) 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /src/azu/handler/request_id.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | module Handler 3 | class RequestID 4 | include HTTP::Handler 5 | 6 | def initialize(@header = "X-Request-ID") 7 | end 8 | 9 | def call(context) 10 | request_id = context.request.headers.fetch(@header) { UUID.random.to_s } 11 | context.response.headers[@header] = request_id 12 | call_next context 13 | end 14 | 15 | private def request_id : String 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/azu/handler/rescuer.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | module Handler 3 | class Rescuer 4 | include HTTP::Handler 5 | 6 | def initialize(@log = Log.for("http.server")) 7 | end 8 | 9 | def call(context : HTTP::Server::Context) 10 | call_next(context) 11 | rescue ex : HTTP::Server::ClientError 12 | @log.debug(exception: ex.cause) { ex.message } 13 | rescue ex : Response::Error 14 | ex.to_s(context) 15 | @log.warn(exception: ex) { "Error Processing Request #{ex.status_code}".colorize(:yellow) } 16 | rescue ex : Exception 17 | Response::Error.from_exception(ex).to_s(context) 18 | @log.error(exception: ex) { "Error Processing Request ".colorize(:red) } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/azu/handler/simple_logger.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | 3 | module Azu 4 | module Handler 5 | class SimpleLogger 6 | include HTTP::Handler 7 | 8 | getter log : ::Log 9 | 10 | def initialize(@log : ::Log = CONFIG.log) 11 | end 12 | 13 | def call(context : HTTP::Server::Context) 14 | call_next(context) 15 | spawn do 16 | log.info { message(context) } 17 | end 18 | context 19 | end 20 | 21 | private def message(context) 22 | time = Time.monotonic 23 | String.build do |str| 24 | str << "HTTP Request".colorize(:green).underline 25 | str << entry(:Method, context.request.method, :green) 26 | str << entry(:Path, context.request.resource, :light_blue) 27 | str << status_entry(:Status, http_status(context.response.status_code)) 28 | str << entry(:Latency, elapsed(Time.monotonic - time), :green) 29 | end 30 | end 31 | 32 | private def format_hash(title, h, str) 33 | str << " #{title}".colorize(:light_gray).bold.underline 34 | 35 | h.map do |k, v| 36 | str << " • ".colorize(:light_gray) 37 | str << "#{k}: ".colorize(:white) 38 | str << case v 39 | when Hash then format_hash("Sub", v, str) 40 | when Array then v.join(", ").colorize(:cyan) 41 | else v.colorize(:cyan) 42 | end 43 | end 44 | 45 | str 46 | end 47 | 48 | private def elapsed(elapsed) 49 | millis = elapsed.total_milliseconds 50 | return "#{millis.round(2)}ms" if millis >= 1 51 | 52 | "#{(millis * 1000).round(2)}µs" 53 | end 54 | 55 | private def entry(key, message, color) 56 | String.build do |str| 57 | str << " • ".colorize(:green) 58 | str << "#{key}: ".colorize(:white) 59 | str << message.colorize(color) 60 | end 61 | end 62 | 63 | private def status_entry(key, message) 64 | String.build do |str| 65 | str << " • ".colorize(:green) 66 | str << "#{key}: ".colorize(:white) 67 | str << message 68 | end 69 | end 70 | 71 | private def http_status(status) 72 | case status 73 | when 200..299 then status.colorize(:green) 74 | when 300..399 then status.colorize(:blue) 75 | when 400..499 then status.colorize(:yellow) 76 | when 500..599 then status.colorize(:red) 77 | else 78 | status.colorize(:white) 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/azu/handler/static.cr: -------------------------------------------------------------------------------- 1 | require "mime" 2 | 3 | module Azu 4 | module Handler 5 | class Static < HTTP::StaticFileHandler 6 | ZIPPED_FILE_EXTENSIONS = %w(.htm .html .txt .css .js .svg .json .xml .otf .ttf .woff .woff2) 7 | 8 | MIME.register(".collection", "font/collection") 9 | MIME.register(".otf", "font/otf") 10 | MIME.register(".sfnt", "font/sfnt") 11 | MIME.register(".ttf", "font/ttf") 12 | MIME.register(".woff", "font/woff") 13 | MIME.register(".woff2", "font/woff2") 14 | MIME.register(".js", "text/javascript") 15 | MIME.register(".png", "text/javascript") 16 | MIME.register(".map", "application/json") 17 | MIME.register(".map", "application/json") 18 | 19 | def initialize(public_dir : String = "public", fallthrough = false, directory_listing = false) 20 | super 21 | end 22 | 23 | def call(context : HTTP::Server::Context) 24 | return allow_get_or_head(context) unless method_get_or_head?(context.request.method) 25 | 26 | original_path = context.request.path.not_nil! 27 | request_path = URI.decode(original_path) 28 | 29 | # File path cannot contains '\0' (NUL) because all filesystem I know 30 | # don't accept '\0' character as file name. 31 | if request_path.includes? '\0' 32 | context.response.status_code = 400 33 | return 34 | end 35 | 36 | is_dir_path = dir_path? original_path 37 | expanded_path = File.expand_path(request_path, "/") 38 | expanded_path += "/" if is_dir_path && !dir_path?(expanded_path) 39 | is_dir_path = dir_path? expanded_path 40 | file_path = File.join(@public_dir, expanded_path) 41 | root_file = "#{@public_dir}#{expanded_path}index.html" 42 | 43 | if is_dir_path && File.exists? root_file 44 | return if etag(context, root_file) 45 | return serve_file(context, root_file) 46 | end 47 | 48 | is_dir_path = Dir.exists?(file_path) && !is_dir_path 49 | if request_path != expanded_path || is_dir_path 50 | redirect_to context, file_redirect_path(expanded_path, is_dir_path) 51 | end 52 | 53 | call_next_with_file_path(context, request_path, file_path) 54 | end 55 | 56 | private def dir_path?(path) 57 | path.ends_with? "/" 58 | end 59 | 60 | private def method_get_or_head?(method) 61 | method == "GET" || method == "HEAD" 62 | end 63 | 64 | private def allow_get_or_head(context) 65 | if @fallthrough 66 | call_next(context) 67 | else 68 | context.response.status_code = 405 69 | context.response.headers.add("Allow", "GET, HEAD") 70 | end 71 | 72 | nil 73 | end 74 | 75 | private def file_redirect_path(path, is_dir_path) 76 | "#{path}/#{is_dir_path ? "/" : ""}" 77 | end 78 | 79 | private def call_next_with_file_path(context, request_path, file_path) 80 | config = static_config 81 | 82 | if Dir.exists?(file_path) 83 | if config["dir_listing"] 84 | context.response.content_type = "text/html" 85 | directory_listing(context.response, request_path, file_path) 86 | else 87 | call_next(context) 88 | end 89 | elsif File.exists?(file_path) 90 | return if etag(context, file_path) 91 | serve_file(context, file_path) 92 | else 93 | call_next(context) 94 | end 95 | end 96 | 97 | private def static_config 98 | {"dir_listing" => @directory_listing, "gzip" => true} 99 | end 100 | 101 | private def etag(context, file_path) 102 | etag = %{W/"#{File.info(file_path).modification_time.to_unix.to_s}"} 103 | context.response.headers["ETag"] = etag 104 | return false if !context.request.headers["If-None-Match"]? || context.request.headers["If-None-Match"] != etag 105 | context.response.headers.delete "Content-Type" 106 | context.response.content_length = 0 107 | context.response.status_code = 304 # not modified 108 | true 109 | end 110 | 111 | private def mime_type(path) 112 | MIME.from_filename path 113 | end 114 | 115 | private def serve_file(env, path : String, mime_type : String? = nil) 116 | config = static_config 117 | file_path = File.expand_path(path, Dir.current) 118 | mime_type ||= mime_type(file_path) 119 | env.response.content_type = mime_type 120 | env.response.headers["Accept-Ranges"] = "bytes" 121 | env.response.headers["X-Content-Type-Options"] = "nosniff" 122 | env.response.headers["Cache-Control"] = "private, max-age=3600" 123 | minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ?? 124 | request_headers = env.request.headers 125 | filesize = File.size(file_path) 126 | File.open(file_path) do |file| 127 | next multipart(file, env) if next_multipart?(env) 128 | 129 | if request_headers.includes_word?("Accept-Encoding", "gzip") && config_gzip?(config) && filesize > minsize && zip_file?(file_path) 130 | gzip_encoding(env, file) 131 | elsif request_headers.includes_word?("Accept-Encoding", "deflate") && config_gzip?(config) && filesize > minsize && zip_file?(file_path) 132 | deflate_endcoding(env, file) 133 | else 134 | env.response.content_length = filesize 135 | IO.copy(file, env.response) 136 | end 137 | end 138 | return 139 | end 140 | 141 | private def zip_file?(path) 142 | ZIPPED_FILE_EXTENSIONS.includes? File.extname(path) 143 | end 144 | 145 | private def next_multipart?(env) 146 | env.request.method == "GET" && env.request.headers.has_key?("Range") 147 | end 148 | 149 | private def config_gzip?(config) 150 | config["gzip"]? 151 | end 152 | 153 | private def gzip_encoding(env, file) 154 | env.response.headers["Content-Encoding"] = "gzip" 155 | Compress::Gzip::Writer.open(env.response) do |deflate| 156 | IO.copy(file, deflate) 157 | end 158 | end 159 | 160 | private def deflate_endcoding(env, file) 161 | env.response.headers["Content-Encoding"] = "deflate" 162 | Compress::Deflate::Writer.open(env.response) do |deflate| 163 | IO.copy(file, deflate) 164 | end 165 | end 166 | 167 | private def multipart(file, env) 168 | fileb = file.size 169 | 170 | range = env.request.headers["Range"] 171 | match = range.match(/bytes=(\d{1,})-(\d{0,})/) 172 | 173 | startb = 0 174 | endb = 0 175 | 176 | if match 177 | if match.size >= 2 178 | startb = match[1].to_i { 0 } 179 | end 180 | 181 | if match.size >= 3 182 | endb = match[2].to_i { 0 } 183 | end 184 | end 185 | 186 | if endb == 0 187 | endb = fileb - 1 188 | end 189 | 190 | if startb < endb && endb < fileb 191 | content_length = 1 + endb - startb 192 | env.response.status_code = 206 193 | env.response.content_length = content_length 194 | env.response.headers["Accept-Ranges"] = "bytes" 195 | env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST 196 | 197 | if startb > 1024 198 | skipped = 0 199 | # file.skip only accepts values less or equal to 1024 (buffer size, undocumented) 200 | until skipped + 1024 > startb 201 | file.skip(1024) 202 | skipped += 1024 203 | end 204 | if skipped - startb > 0 205 | file.skip(skipped - startb) 206 | end 207 | else 208 | file.skip(startb) 209 | end 210 | 211 | IO.copy(file, env.response, content_length) 212 | else 213 | env.response.content_length = fileb 214 | env.response.status_code = 200 # Range not satisfiable, see 4.4 Note 215 | IO.copy(file, env.response) 216 | end 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /src/azu/handler/throttle.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | module Handler 3 | # Handler for protecting against Denial-of-service attacks and/or to rate limit requests. 4 | # 5 | # DDoS errors occur when the client is sending too many requests at once. 6 | # these attacks are essentially rate-limiting problems. 7 | # 8 | # By blocking a certain IP address, or allowing a certain IP address to make a limited number of 9 | # requests over a certain period of time, you are building the first line of defense in blocking DDoS attacks. 10 | # http://en.wikipedia.org/wiki/Denial-of-service_attack. 11 | # 12 | # ### Options 13 | # 14 | # * **interval** Duration in seconds until the request counter is reset. Defaults to 5 15 | # * **duration** Duration in seconds that a remote address will be blocked. Defaults to 900 (15 minutes) 16 | # * **threshold** Number of requests allowed. Defaults to 100 17 | # * **blacklist** Array of remote addresses immediately considered malicious. 18 | # * **whitelist** Array of remote addresses which bypass Deflect. 19 | # 20 | # ### Usage 21 | # 22 | # ``` 23 | # Azu::Throttle.new( 24 | # interval: 5, 25 | # duration: 5, 26 | # threshold: 10, 27 | # blacklist: ["111.111.111.111"], 28 | # whitelist: ["222.222.222.222"] 29 | # ) 30 | # ``` 31 | class Throttle 32 | include HTTP::Handler 33 | 34 | RETRY_AFTER = "Retry-After" 35 | CONTENT_TYPE = "Content-Type" 36 | CONTENT_LENGTH = "Content-Length" 37 | REMOTE_ADDR = "REMOTE_ADDR" 38 | MAPPER = {} of String => Hash(String, Int32 | Int64) 39 | 40 | private getter log : ::Log = CONFIG.log, 41 | interval : Int32 = 5, 42 | duration : Int32 = 900, 43 | threshold : Int32 = 100, 44 | blacklist : Array(String) = [] of String, 45 | whitelist : Array(String) = [] of String, 46 | remote = "" 47 | 48 | def initialize(@interval, @duration, @threshold, @blacklist, @whitelist) 49 | @mutex = Mutex.new 50 | end 51 | 52 | def call(context : HTTP::Server::Context) 53 | return call_next(context) unless deflect?(context) 54 | too_many_requests(context) 55 | end 56 | 57 | private def deflect?(context) 58 | @remote = context.request.headers[REMOTE_ADDR] 59 | 60 | return false if whitelisted? 61 | return true if blacklisted? 62 | 63 | @mutex.synchronize { watch } 64 | end 65 | 66 | private def too_many_requests(context) 67 | context.response.headers[CONTENT_TYPE] = "text/plain" 68 | context.response.headers[CONTENT_LENGTH] = "0" 69 | context.response.headers[RETRY_AFTER] = "#{map["block_expires"]}" 70 | context.response.status_code = HTTP::Status::TOO_MANY_REQUESTS.value 71 | context.response.close 72 | end 73 | 74 | private def map 75 | MAPPER[remote] ||= { 76 | "expires" => Time.utc.to_unix + interval, 77 | "requests" => 0, 78 | } 79 | end 80 | 81 | private def watch 82 | increment_requests 83 | 84 | clear! if watch_expired? && !blocked? 85 | clear! if blocked? && block_expired? 86 | block! if watching? && exceeded_request_threshold? 87 | 88 | blocked? 89 | end 90 | 91 | private def blacklisted? 92 | blacklist.includes?(remote) 93 | end 94 | 95 | private def whitelisted? 96 | whitelist.includes?(remote) 97 | end 98 | 99 | private def block! 100 | return if blocked? 101 | map["block_expires"] = Time.utc.to_unix + duration 102 | log.warn { "#{remote} blocked" } 103 | end 104 | 105 | private def clear! 106 | return unless watching? 107 | MAPPER.delete(remote) 108 | log.warn { "#{remote} released" } if blocked? 109 | end 110 | 111 | private def blocked? 112 | map.has_key?("block_expires") 113 | end 114 | 115 | private def block_expired? 116 | map["block_expires"] < Time.utc.to_unix rescue false 117 | end 118 | 119 | private def watching? 120 | MAPPER.has_key?(remote) 121 | end 122 | 123 | private def increment_requests 124 | map["requests"] += 1 125 | end 126 | 127 | private def watch_expired? 128 | map["expires"] <= Time.utc.to_unix 129 | end 130 | 131 | private def exceeded_request_threshold? 132 | map["requests"] > threshold 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /src/azu/http_request.cr: -------------------------------------------------------------------------------- 1 | require "mime" 2 | 3 | # :nodoc: 4 | class HTTP::Request 5 | @path_params = {} of String => String 6 | @accept : Array(MIME::MediaType)? = nil 7 | 8 | def content_type : MIME::MediaType 9 | if content = headers["Content-Type"]? 10 | MIME::MediaType.parse(content) 11 | else 12 | MIME::MediaType.parse("text/plain") 13 | end 14 | end 15 | 16 | def path_params 17 | @path_params 18 | end 19 | 20 | def path_params=(params) 21 | @path_params = params 22 | end 23 | 24 | def accept : Array(MIME::MediaType) | Nil 25 | @accept ||= ( 26 | if header = headers["Accept"]? 27 | header.split(",").map { |a| MIME::MediaType.parse(a) }.sort do |a, b| 28 | (b["q"]?.try &.to_f || 1.0) <=> (a["q"]?.try &.to_f || 1.0) 29 | end 30 | end 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/azu/log_format.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | require "log" 3 | require "benchmark" 4 | 5 | module Azu 6 | module SQL 7 | struct Formatter < Log::StaticFormatter 8 | getter orange_red = Colorize::ColorRGB.new(255, 140, 0) 9 | 10 | def run 11 | entry_data = @entry.data 12 | @io << " AZU ".colorize.fore(:white).back(:blue) 13 | @io << " " 14 | @io << @entry.timestamp.to_s("%a %m/%d/%Y %I:%M:%S") 15 | @io << " ⤑ " 16 | @io << severity_colored(@entry.severity) 17 | @io << " ⤑ " 18 | @io << Log.progname.capitalize.colorize.bold 19 | @io << " ⤑ SQL" 20 | @io << "\n" 21 | @io << SQL.colorize_query(entry_data[:query].as_s) 22 | @io << " \n\t" << entry_data[:args].colorize.magenta if entry_data[:args]? 23 | @io << "\n" 24 | end 25 | 26 | def severity_colored(severity) 27 | output = " #{severity} ".colorize.fore(:white) 28 | case severity 29 | when ::Log::Severity::Info then output.back(:green).bold 30 | when ::Log::Severity::Debug then output.back(:blue).bold 31 | when ::Log::Severity::Warn then output.back(orange_red).bold 32 | when ::Log::Severity::Error, ::Log::Severity::Fatal then output.back(:red).bold 33 | else 34 | output.back(:black).bold 35 | end 36 | end 37 | end 38 | 39 | class_property colorize : Bool = STDOUT.tty? && STDERR.tty? 40 | 41 | private SQL_KEYWORDS = Set(String).new(%w( 42 | ADD ALL ALTER ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC 43 | BEGIN BOTH BY CASE CAST CHECK COLLATE COLUMN COMMIT CONSTRAINT COUNT CREATE CROSS 44 | CURRENT_DATE CURRENT_ROLE CURRENT_TIME CURRENT_TIMESTAMP 45 | CURRENT_USER CURSOR DECLARE DEFAULT DELETE DEFERRABLE DESC 46 | DISTINCT DROP DO ELSE END EXCEPT EXISTS FALSE FETCH FULL FOR FOREIGN FROM GRANT 47 | GROUP HAVING IF IN INDEX INNER INSERT INITIALLY INTERSECT INTO JOIN LAGGING 48 | LEADING LIMIT LEFT LOCALTIME LOCALTIMESTAMP NATURAL NEW NOT NULL OFF OFFSET 49 | OLD ON ONLY OR ORDER OUTER PLACING PRIMARY REFERENCES RELEASE RETURNING 50 | RIGHT ROLLBACK SAVEPOINT SELECT SESSION_USER SET SOME SYMMETRIC 51 | TABLE THEN TO TRAILING TRIGGER TRUE UNION UNIQUE UPDATE USER USING VALUES 52 | WHEN WHERE WINDOW START 53 | )) 54 | 55 | def self.colorize_query(qry : String) 56 | return qry unless @@colorize 57 | 58 | o = qry.to_s.split(/([a-zA-Z0-9_]+)/).join do |word| 59 | if SQL_KEYWORDS.includes?(word.upcase) 60 | if %w(START INSERT UPDATE CREATE ALTER COMMIT SELECT FROM WHERE GROUP LEFT RIGHT JOIN).includes?(word.upcase) 61 | "\n\t#{word.colorize.bold.blue}" 62 | else 63 | word.colorize.bold.blue.to_s 64 | end 65 | elsif word =~ /\d+/ 66 | word.colorize.red 67 | else 68 | word.colorize.white 69 | end 70 | end 71 | o.gsub(/(--.*)$/, &.colorize.dark_gray) 72 | end 73 | 74 | def self.display_mn_sec(x : Float64) : String 75 | mn = x.to_i / 60 76 | sc = x.to_i % 60 77 | 78 | {mn > 9 ? mn : "0#{mn}", sc > 9 ? sc : "0#{sc}"}.join("mn") + "s" 79 | end 80 | 81 | def self.display_time(x : Float64) : String 82 | if (x > 60) 83 | display_mn_sec(x) 84 | elsif (x > 1) 85 | ("%.2f" % x) + "s" 86 | elsif (x > 0.001) 87 | (1_000 * x).to_i.to_s + "ms" 88 | else 89 | (1_000_000 * x).to_i.to_s + "µs" 90 | end 91 | end 92 | end 93 | 94 | # :nodoc: 95 | struct LogFormat < Log::StaticFormatter 96 | getter orange_red = Colorize::ColorRGB.new(255, 140, 0) 97 | 98 | def run 99 | string " AZU ".colorize.fore(:white).back(:blue) 100 | string " " 101 | string @entry.timestamp.to_s("%a %m/%d/%Y %I:%M:%S") 102 | string " ⤑ " 103 | string severity_colored(@entry.severity) 104 | string " ⤑ " 105 | string Log.progname.capitalize.colorize.bold 106 | string " ⤑ " 107 | message 108 | exception 109 | end 110 | 111 | def exception(*, before = '\n', after = nil) 112 | if ex = @entry.exception 113 | @io << before 114 | 115 | if ex.responds_to? :title 116 | @io << " ⤑ Title: ".colorize(:light_gray) 117 | @io << ex.title.colorize(:cyan) 118 | @io << "\n" 119 | end 120 | 121 | if ex.responds_to? :status 122 | @io << " ⤑ Status: ".colorize(:light_gray) 123 | @io << ex.status_code.colorize(:cyan) 124 | @io << "\n" 125 | end 126 | 127 | if ex.responds_to? :link 128 | @io << " ⤑ Link: ".colorize(:light_gray) 129 | @io << ex.link.colorize(:cyan) 130 | @io << "\n" 131 | end 132 | 133 | if ex.responds_to? :detail 134 | @io << " ⤑ Detail: ".colorize(:light_gray) 135 | @io << ex.detail.colorize(:cyan) 136 | @io << "\n" 137 | end 138 | 139 | if ex.responds_to? :source 140 | @io << " ⤑ Source: ".colorize(:light_gray) 141 | @io << ex.source.colorize(:cyan) 142 | @io << "\n" 143 | end 144 | 145 | @io << " ⤑ Backtrace: ".colorize(:light_gray) 146 | ex.inspect_with_backtrace(@io).colorize(:cyan) 147 | @io << after 148 | end 149 | end 150 | 151 | private def severity_colored(severity) 152 | output = " #{severity} ".colorize.fore(:white) 153 | case severity 154 | when ::Log::Severity::Info then output.back(:green).bold 155 | when ::Log::Severity::Debug then output.back(:blue).bold 156 | when ::Log::Severity::Warn then output.back(orange_red).bold 157 | when ::Log::Severity::Error, ::Log::Severity::Fatal then output.back(:red).bold 158 | else 159 | output.back(:black).bold 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /src/azu/markup.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # Allows you to write HTML markup in plain crystal code 3 | module Markup 4 | private VOID_TAGS = %i( 5 | area 6 | base 7 | br 8 | col 9 | embed 10 | frame 11 | hr 12 | img 13 | input 14 | link 15 | meta 16 | param 17 | source 18 | track 19 | wbr 20 | ) 21 | 22 | private NON_VOID_TAGS = %i( 23 | a 24 | abbr 25 | acronym 26 | address 27 | article 28 | aside 29 | audio 30 | b 31 | bdi 32 | bdo 33 | big 34 | blockquote 35 | body 36 | button 37 | canvas 38 | caption 39 | cite 40 | code 41 | colgroup 42 | data 43 | datalist 44 | dd 45 | del 46 | dfn 47 | details 48 | dialog 49 | div 50 | dl 51 | dt 52 | em 53 | fieldset 54 | figcaption 55 | figure 56 | footer 57 | form 58 | frameset 59 | h1 60 | h2 61 | h3 62 | h4 63 | h5 64 | h6 65 | head 66 | header 67 | html 68 | i 69 | iframe 70 | ins 71 | kbd 72 | label 73 | legend 74 | li 75 | main 76 | mark 77 | map 78 | meter 79 | nav 80 | noframes 81 | noscript 82 | object 83 | ol 84 | optgroup 85 | option 86 | output 87 | para 88 | pre 89 | progress 90 | q 91 | rb 92 | rp 93 | rt 94 | rtc 95 | ruby 96 | s 97 | samp 98 | select 99 | script 100 | section 101 | small 102 | span 103 | strong 104 | style 105 | sub 106 | sup 107 | summary 108 | table 109 | tbody 110 | td 111 | template 112 | textarea 113 | tfoot 114 | th 115 | thead 116 | time 117 | title 118 | tr 119 | tt 120 | u 121 | ul 122 | var 123 | video 124 | ) 125 | @view = IO::Memory.new 126 | 127 | # :nodoc: 128 | enum Version 129 | HTML_4_01 130 | XHTML_1_0 131 | XHTML_1_1 132 | HTML_5 133 | end 134 | 135 | def to_s(io : IO) : Nil 136 | io << @view 137 | end 138 | 139 | private def doctype : Nil 140 | case version 141 | when .html_4_01? 142 | raw "" 144 | when .xhtml_1_0? 145 | raw "" 147 | when .xhtml_1_1? 148 | raw "" 150 | else 151 | raw "" 152 | end 153 | end 154 | 155 | {% for t in VOID_TAGS %} 156 | private def {{ t.id }}(**attr) : Nil 157 | tag {{ t }}, **attr 158 | end 159 | {% end %} 160 | 161 | {% for t in NON_VOID_TAGS %} 162 | private def {{ t.id }}(**attr) : Nil 163 | tag {{ t }}, **attr do end 164 | end 165 | 166 | private def {{ t.id }}(__ label : String, **attr) : Nil 167 | tag {{ t }}, **attr do text(label) end 168 | end 169 | 170 | private def {{ t.id }}(**attr, &b : Proc(Nil)) : Nil 171 | tag {{ t }}, **attr, &b 172 | end 173 | {% end %} 174 | 175 | private def tag(__ name : Symbol, **attr) : Nil 176 | tag name.to_s, **attr 177 | end 178 | 179 | private def tag(__ name : Symbol, **attr, &b : Proc(Nil)) : Nil 180 | tag name.to_s, **attr, &b 181 | end 182 | 183 | private def tag(__ name : String, **attr) : Nil 184 | if jsx?(name) || xhtml? 185 | raw "<#{name}#{build_attrs(attr)} />" 186 | else 187 | raw "<#{name}#{build_attrs(attr)}>" 188 | end 189 | end 190 | 191 | private def tag(__ name : String, **attr, & : Proc(Nil)) : Nil 192 | raw "<#{name}#{build_attrs(attr)}>" 193 | yield 194 | raw "" 195 | end 196 | 197 | private def text(text) : Nil 198 | raw esc(text) 199 | end 200 | 201 | private def raw(text) : Nil 202 | @view << text.to_s 203 | end 204 | 205 | private def version : Version 206 | Version::HTML_5 207 | end 208 | 209 | private def build_attrs(attrs = NamedTuple.new) : String 210 | attr_str = attrs.map do |key, val| 211 | k = key.to_s.downcase.gsub /[^a-z0-9\-]/, '-' 212 | 213 | if val.nil? 214 | xhtml? ? "#{k}='#{esc(k)}'" : k 215 | else 216 | "#{k}='#{esc(val)}'" 217 | end 218 | end 219 | 220 | attr_str.empty? ? "" : " #{attr_str.join(' ')}" 221 | end 222 | 223 | private def esc(text) : String 224 | ::HTML.escape text.to_s 225 | end 226 | 227 | private def xhtml? : Bool 228 | version.to_s.starts_with? "XHTML_" 229 | end 230 | 231 | private def jsx?(name : String) : Bool 232 | name[0].uppercase? 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /src/azu/method.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # :nodoc: 3 | enum Method 4 | WebSocket 5 | # The CONNECT method establishes a tunnel to the server identified by the target resource. 6 | Connect 7 | # The DELETE method deletes the specified resource. 8 | Delete 9 | # The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. 10 | Get 11 | # The HEAD method asks for a response identical to that of a GET request, but without the response body. 12 | Head 13 | # The OPTIONS method is used to describe the communication options for the target resource. 14 | Options 15 | # The PATCH method is used to apply partial modifications to a resource. 16 | Patch 17 | # The POST method is used to submit an entity to the specified resource, often causing a change in state or side effects on the server. 18 | Post 19 | # The PUT method replaces all current representations of the target resource with the request payload. 20 | Put 21 | # The TRACE method performs a message loop-back test along the path to the target resource. 22 | Trace 23 | 24 | def add_options? 25 | ![Trace, Connect, Options, Head].includes? self 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/azu/params.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "json" 3 | 4 | module Azu 5 | class Params(Request) 6 | CONTENT_TYPE = "Content-Type" 7 | URL_ENCODED_FORM = "application/x-www-form-urlencoded" 8 | MULTIPART_FORM = "multipart/form-data" 9 | APPLICATION_JSON = "application/json" 10 | 11 | getter files = Hash(String, Multipart::File).new 12 | getter query : HTTP::Params 13 | getter form : HTTP::Params 14 | getter path : Hash(String, String) 15 | getter json : String? = nil 16 | 17 | def initialize(request : HTTP::Request) 18 | @query = request.query_params 19 | @path = request.path_params 20 | @form = HTTP::Params.new 21 | 22 | case request.content_type.sub_type 23 | when "json" 24 | @json = request.body.not_nil!.gets_to_end 25 | when "x-www-form-urlencoded" 26 | @form = Form.parse(request) 27 | when "form-data" 28 | @form, @files = Multipart.parse(request) 29 | else 30 | @form = HTTP::Params.new 31 | end 32 | end 33 | 34 | def [](key) 35 | (form[key]? || path[key]? || query[key]?).not_nil! 36 | end 37 | 38 | def []?(key) 39 | form[key]? || path[key]? || query[key]? 40 | end 41 | 42 | def fetch_all(key) 43 | return form.fetch_all(key) if form.has_key? key 44 | return [path[key]] if path.has_key? key 45 | query.fetch_all(key) 46 | end 47 | 48 | def each 49 | to_h.each do |k, v| 50 | yield k, v 51 | end 52 | end 53 | 54 | def to_h 55 | hash = Hash(String, String).new 56 | hash.merge! query.to_h 57 | hash.merge! path 58 | hash.merge! form.to_h 59 | hash 60 | end 61 | 62 | def to_query 63 | String.build do |s| 64 | to_h.each do |key, value| 65 | s << key << "=" << value << "&" 66 | end 67 | end 68 | end 69 | 70 | module Multipart 71 | struct File 72 | getter file : ::File 73 | getter filename : String? 74 | getter headers : HTTP::Headers 75 | getter creation_time : Time? 76 | getter modification_time : Time? 77 | getter read_time : Time? 78 | getter size : UInt64? 79 | 80 | def initialize(upload) 81 | @filename = upload.filename 82 | @file = ::File.tempfile(filename) 83 | ::File.open(@file.path, "w") do |f| 84 | ::IO.copy(upload.body, f) 85 | end 86 | @headers = upload.headers 87 | @creation_time = upload.creation_time 88 | @modification_time = upload.modification_time 89 | @read_time = upload.read_time 90 | @size = upload.size 91 | end 92 | end 93 | 94 | def self.parse(request : HTTP::Request) 95 | multipart_params = HTTP::Params.new 96 | files = Hash(String, Multipart::File).new 97 | 98 | HTTP::FormData.parse(request) do |upload| 99 | next unless upload 100 | filename = upload.filename 101 | if filename.is_a?(String) && !filename.empty? 102 | files[upload.name] = File.new(upload: upload) 103 | else 104 | multipart_params[upload.name] = upload.body.gets_to_end 105 | end 106 | end 107 | {multipart_params, files} 108 | end 109 | end 110 | 111 | module Form 112 | def self.parse(request : HTTP::Request) 113 | parse_part(request.body) 114 | end 115 | 116 | def self.parse_part(input : IO) : HTTP::Params 117 | HTTP::Params.parse(input.gets_to_end) 118 | end 119 | 120 | def self.parse_part(input : String) : HTTP::Params 121 | HTTP::Params.parse(input) 122 | end 123 | 124 | def self.parse_part(input : Nil) : HTTP::Params 125 | HTTP::Params.parse("") 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /src/azu/request.cr: -------------------------------------------------------------------------------- 1 | require "mime" 2 | 3 | module Azu 4 | # Every HTTP request message has a specific form: 5 | # 6 | # ```text 7 | # POST /path HTTP/1.1 8 | # Host: example.com 9 | # 10 | # foo=bar&baz=bat 11 | # ``` 12 | # 13 | # A HTTP message is either a request from a client to a server or a response from a server to a client 14 | # The `Azu::Request` represents a client request and it provides additional helper methods to access different 15 | # parts of the HTTP Request extending the Crystal `HTTP::Request` standard library class. 16 | # These methods are define in the `Helpers` class. 17 | # 18 | # Azu Request are design by contract in order to enforce correctness. What this means is that requests 19 | # are strictly typed and can have pre-conditions. With this concept Azu::Request provides a consice way 20 | # to type safe and validate requests objects. 21 | # 22 | # Azu Requests benefits: 23 | # 24 | # * Self documented request objects. 25 | # * Type safe requests and parameters 26 | # * Enables Focused and effective testing. 27 | # * Json body requests render object instances. 28 | # 29 | # Azu Requests contracts is provided by tight integration with the [Schema](https://github.com/eliasjpr/schema) shard 30 | # 31 | # ### Example Use: 32 | # 33 | # ``` 34 | # class UserRequest 35 | # include Azu::Request 36 | # 37 | # query name : String, message: "Param name must be present.", presence: true 38 | # end 39 | # ``` 40 | # 41 | # ### Initializers 42 | # 43 | # ``` 44 | # UserRequest.from_json(pyaload: String) 45 | # UserRequest.new(params: Hash(String, String)) 46 | # ``` 47 | # 48 | # ### Available Methods 49 | # 50 | # ``` 51 | # getters - For each of the params 52 | # valid? - Bool 53 | # validate! - True or Raise Error 54 | # errors - Errors(T, S) 55 | # rules - Rules(T, S) 56 | # params - Original params payload 57 | # to_json - Outputs JSON 58 | # to_yaml - Outputs YAML 59 | # ``` 60 | module Request 61 | macro included 62 | include JSON::Serializable 63 | include Schema::Definition 64 | include Schema::Validation 65 | 66 | def error_messages 67 | errors.map(&.message) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /src/azu/response.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # A response is a message sent from a server to a client 3 | # 4 | # `Azu:Response` represents an interface for all Azu server responses. You can 5 | # still use Crystal `HTTP::Response` class to generete response messages. 6 | # 7 | # The response `#status` and `#headers` must be configured before writing the response body. 8 | # Once response output is written, changing the` #status` and `#headers` properties has no effect. 9 | # 10 | # ## Defining Responses 11 | # 12 | # 13 | # ``` 14 | # module MyApp 15 | # class Home::Page 16 | # include Response::Html 17 | # 18 | # TEMPLATE_PATH = "home/index.jinja" 19 | # 20 | # def html 21 | # render TEMPLATE_PATH, assigns 22 | # end 23 | # 24 | # def assigns 25 | # { 26 | # "welcome" => "Hello World!", 27 | # } 28 | # end 29 | # end 30 | # end 31 | # ``` 32 | module Response 33 | abstract def render 34 | 35 | class Empty 36 | include Response 37 | 38 | def render; end 39 | end 40 | 41 | class Error < Exception 42 | include Azu::Response 43 | 44 | property status : HTTP::Status = HTTP::Status::INTERNAL_SERVER_ERROR 45 | property title : String = "Internal Server Error" 46 | property detail : String = "Internal Server Error" 47 | property source : String = "" 48 | property errors : Array(String) = [] of String 49 | 50 | private getter templates : Templates = CONFIG.templates 51 | private getter env : Environment = CONFIG.env 52 | private getter log : ::Log = CONFIG.log 53 | 54 | def initialize(@detail = "", @source = "", @errors = Array(String).new) 55 | end 56 | 57 | def initialize(@title, @status, @errors) 58 | end 59 | 60 | def self.from_exception(ex, status = 500) 61 | error = new( 62 | title: ex.message || "Error", 63 | status: HTTP::Status.from_value(status), 64 | errors: [] of String 65 | ) 66 | error.detail=(ex.cause.to_s || "En server error occurred") 67 | error 68 | end 69 | 70 | def link 71 | "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/#{status}" 72 | end 73 | 74 | def html(context) 75 | return ExceptionPage.new(context, self).to_s if env.development? 76 | html 77 | end 78 | 79 | def html 80 | render "error.html", { 81 | status: status_code, 82 | link: link, 83 | title: title, 84 | detail: detail, 85 | source: source, 86 | errors: errors, 87 | backtrace: inspect_with_backtrace, 88 | } 89 | end 90 | 91 | def status_code 92 | status.value 93 | end 94 | 95 | def render(template : String, data) 96 | templates.load(template).render(data) 97 | end 98 | 99 | def xml 100 | messages = errors.map { |e| "#{e}" }.join("") 101 | <<-XML 102 | 103 | 104 | #{detail} 105 | #{source} 106 | #{messages} 107 | 108 | XML 109 | end 110 | 111 | def json 112 | { 113 | Status: status, 114 | Link: link, 115 | Title: title, 116 | Detail: detail, 117 | Source: source, 118 | Errors: errors, 119 | Backtrace: inspect_with_backtrace, 120 | }.to_json 121 | end 122 | 123 | def text 124 | <<-TEXT 125 | Status: #{status} 126 | Link: #{link} 127 | Title: #{title} 128 | Detail: #{detail} 129 | Source: #{source} 130 | Errors: #{errors} 131 | Backtrace: #{inspect_with_backtrace} 132 | TEXT 133 | end 134 | 135 | def render 136 | end 137 | 138 | def to_s(context : HTTP::Server::Context) 139 | context.response.status_code = status_code 140 | if accept = context.request.accept 141 | accept.each do |a| 142 | context.response << case a.sub_type.not_nil! 143 | when .includes?("html") then html(context) 144 | when .includes?("json") then json 145 | when .includes?("xml") then xml 146 | when .includes?("plain") then text 147 | else text 148 | end 149 | break 150 | end 151 | end 152 | end 153 | end 154 | 155 | class Forbidden < Error 156 | getter title = "Forbidden" 157 | getter detail = "The server understood the request but refuses to authorize it." 158 | getter status : HTTP::Status = HTTP::Status::FORBIDDEN 159 | end 160 | 161 | class BadRequest < Error 162 | getter title = "Bad Request" 163 | getter detail = "The server cannot or will not process the request due to something that is perceived to be a client error." 164 | getter status : HTTP::Status = HTTP::Status::BAD_REQUEST 165 | end 166 | 167 | class NotFound < Error 168 | def initialize(path : String) 169 | @title = "Not found" 170 | @detail = "The server can't find the requested resource." 171 | @status = HTTP::Status::NOT_FOUND 172 | @source = path 173 | end 174 | 175 | def render(template : String, data) 176 | templates.load("#{template}").render(data) 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /src/azu/router.cr: -------------------------------------------------------------------------------- 1 | require "http/web_socket" 2 | 3 | module Azu 4 | # Defines an Azu Router 5 | # 6 | # The router provides a set of methods for mapping routes that dispatch to 7 | # specific endpoints or handers. For example 8 | # 9 | # ``` 10 | # MyAppWeb.router do 11 | # root :web, ExampleApp::HelloWorld 12 | # ws "/hi", ExampleApp::ExampleChannel 13 | # 14 | # routes :web, "/test" do 15 | # get "/hello/", ExampleApp::HelloWorld 16 | # get "/hello/:name", ExampleApp::HtmlEndpoint 17 | # get "/hello/json", ExampleApp::JsonEndpoint 18 | # end 19 | # end 20 | # ``` 21 | # 22 | # You can use most common HTTP verbs: GET, POST, PUT, PATCH, DELETE, TRACE 23 | # and OPTIONS. 24 | # 25 | # ``` 26 | # endpoint = ->(env) { [200, {}, ['Hello from Hanami!']] } 27 | # 28 | # get '/hello', to: endpoint 29 | # post '/hello', to: endpoint 30 | # put '/hello', to: endpoint 31 | # patch '/hello', to: endpoint 32 | # delete '/hello', to: endpoint 33 | # trace '/hello', to: endpoint 34 | # options '/hello', to: endpoint 35 | # ``` 36 | class Router 37 | alias Path = String 38 | RADIX = Radix::Tree(Route).new 39 | RESOURCES = %w(connect delete get head options patch post put trace) 40 | METHOD_OVERRIDE = "_method" 41 | 42 | record Route, 43 | endpoint : HTTP::Handler, 44 | resource : String, 45 | method : Method 46 | 47 | class DuplicateRoute < Exception 48 | end 49 | 50 | # The Router::Builder class allows you to build routes more easily 51 | # 52 | # ``` 53 | # routes :web, "/test" do 54 | # get "/hello/", ExampleApp::HelloWorld 55 | # get "/hello/:name", ExampleApp::HtmlEndpoint 56 | # get "/hello/json", ExampleApp::JsonEndpoint 57 | # end 58 | # ``` 59 | class Builder 60 | forward_missing_to @router 61 | 62 | def initialize(@router : Router, @scope : String = "") 63 | end 64 | end 65 | 66 | {% for method in RESOURCES %} 67 | def {{method.id}}(path : Router::Path, handler : HTTP::Handler) 68 | method = Method.parse({{method}}) 69 | add path, handler, method 70 | 71 | {% if method == "get" %} 72 | add path, handler, Method::Head 73 | 74 | {% if !%w(trace connect options head).includes? method %} 75 | add path, handler, Method::Options if method.add_options? 76 | {% end %} 77 | {% end %} 78 | end 79 | {% end %} 80 | 81 | # Adds scoped routes 82 | def routes(scope : String = "") 83 | with Builder.new(self, scope) yield 84 | end 85 | 86 | def process(context : HTTP::Server::Context) 87 | method_override(context) 88 | result = RADIX.find path(context) 89 | return not_found(context).to_s(context) unless result.found? 90 | context.request.path_params = result.params 91 | route = result.payload 92 | route.endpoint.call(context).to_s 93 | end 94 | 95 | private def not_found(context) 96 | ex = Response::NotFound.new(context.request.path) 97 | Log.error(exception: ex) { "Router: Error Processing Request ".colorize(:yellow) } 98 | ex 99 | end 100 | 101 | # Registers the main route of the application 102 | # 103 | # ``` 104 | # root :web, ExampleApp::HelloWorld 105 | # ``` 106 | def root(endpoint : HTTP::Handler) 107 | RADIX.add "/get/", Route.new(endpoint: endpoint, resource: "/get/", method: Method::Get) 108 | end 109 | 110 | # Registers a websocket route 111 | # 112 | # ``` 113 | # ws "/hi", ExampleApp::ExampleChannel 114 | # ``` 115 | def ws(path : String, channel : Channel.class) 116 | handler = HTTP::WebSocketHandler.new do |socket, context| 117 | channel.new(socket).call(context) 118 | end 119 | resource = "/ws#{path}" 120 | RADIX.add resource, Route.new(handler, resource, Method::WebSocket) 121 | end 122 | 123 | # Registers a route for a given path 124 | # 125 | # ``` 126 | # add path: '/proc', endpoint: ->(env) { [200, {}, ['Hello from Hanami!']] }, method: Method::Get 127 | # add path: '/endpoint', endpoint: Handler.new, method: Method::Get 128 | # ``` 129 | def add(path : Path, endpoint : HTTP::Handler, method : Method = Method::Any) 130 | resource = "/#{method.to_s.downcase}#{path}" 131 | RADIX.add resource, Route.new(endpoint, resource, method) 132 | rescue ex : Radix::Tree::DuplicateError 133 | raise DuplicateRoute.new("http_method: #{method}, path: #{path}, endpoint: #{endpoint}") 134 | end 135 | 136 | private def path(context) 137 | upgraded = upgrade?(context) 138 | String.build do |str| 139 | str << "/" 140 | str << "ws" if upgraded 141 | str << context.request.method.downcase unless upgraded 142 | str << context.request.path.rstrip('/') 143 | end 144 | end 145 | 146 | private def method_override(context) 147 | if value = context.request.query_params[METHOD_OVERRIDE]? 148 | context.request.method = value.upcase 149 | end 150 | end 151 | 152 | private def upgrade?(context) 153 | return unless upgrade = context.request.headers["Upgrade"]? 154 | return unless upgrade.compare("websocket", case_insensitive: true) == 0 155 | context.request.headers.includes_word?("Connection", "Upgrade") 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /src/azu/spark.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | 3 | module Azu 4 | class Spark < Channel 5 | COMPONENTS = {} of String => Component 6 | GC_INTERVAL = 10.seconds 7 | 8 | gc_sweep 9 | 10 | def self.javascript_tag 11 | <<-JS 12 | 13 | JS 14 | end 15 | 16 | private def self.gc_sweep 17 | spawn do 18 | loop do 19 | sleep GC_INTERVAL 20 | COMPONENTS.reject! do |key, component| 21 | component.dicconnected? && ( 22 | component.mounted? || component.age > GC_INTERVAL 23 | ) 24 | end 25 | end 26 | end 27 | end 28 | 29 | def on_binary(binary); end 30 | 31 | def on_ping(message); end 32 | 33 | def on_pong(message); end 34 | 35 | def on_connect 36 | end 37 | 38 | def on_close(code : HTTP::WebSocket::CloseCode | Int? = nil, message = nil) 39 | COMPONENTS.each do |id, component| 40 | component.unmount 41 | COMPONENTS.delete id 42 | rescue KeyError 43 | end 44 | end 45 | 46 | def on_message(message) 47 | json = JSON.parse(message) 48 | 49 | if channel = json["subscribe"]? 50 | spark = channel.to_s 51 | COMPONENTS[spark].connected = true 52 | COMPONENTS[spark].socket = socket 53 | COMPONENTS[spark].mount 54 | elsif event_name = json["event"]? 55 | spark = json["channel"].not_nil! 56 | data = json["data"].not_nil!.as_s 57 | COMPONENTS[spark].on_event(event_name.as_s, data) 58 | end 59 | rescue IO::Error 60 | rescue ex 61 | ex.inspect STDERR 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/azu/templates.cr: -------------------------------------------------------------------------------- 1 | module Azu 2 | # Templates are used by Azu when rendering responses. 3 | # 4 | # Since many views render significant content, for example a 5 | # whole HTML file, it is common to put these files into a particular 6 | # directory, typically "src/templates". 7 | # 8 | # This module provides conveniences for reading all files from a particular 9 | # directory and embedding them into a single module. Imagine you have a directory with templates: 10 | # 11 | # Templates::Renderable will define a private function named `render(template : String, data)` with 12 | # one clause per file system template. 13 | # 14 | # ``` 15 | # render(template : String, data) 16 | # ``` 17 | class Templates 18 | getter crinja = Crinja.new 19 | getter path : Array(String) 20 | getter error_path : String 21 | 22 | module Renderable 23 | private def view(template : String = page_path, data = Hash(String, String).new) 24 | CONFIG.templates.load(template).render(data) 25 | end 26 | 27 | def page_path 28 | "#{self.class.name.split("::").join("/").underscore.downcase}.jinja" 29 | end 30 | end 31 | 32 | def initialize(@path : Array(String), @error_path : String) 33 | crinja.loader = Crinja::Loader::FileSystemLoader.new([error_path] + path) 34 | end 35 | 36 | def path=(path : String) 37 | reload { @path << Path[path].expand.to_s } 38 | end 39 | 40 | def error_path=(path : String) 41 | reload { @error_path = Path[path].expand.to_s } 42 | end 43 | 44 | def load(template : String) 45 | crinja.get_template template 46 | end 47 | 48 | private def reload 49 | with self yield 50 | path << error_path 51 | crinja.loader = Crinja::Loader::FileSystemLoader.new(path) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/azu/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{status}} - {{title}} 9 | 10 | 102 | 103 | 104 | 108 | 109 | 110 | 111 | 112 |
113 |
114 |

{{status}}

115 |

{{title}}

116 |

{{detail}}

117 |

{{source}}

118 |
    119 | {% for error in errors %} 120 |
  1. {{error}}
  2. 121 | {% endfor %} 122 |
123 | Back to homepage 124 |
125 |
126 | 127 | 128 | 129 | --------------------------------------------------------------------------------