├── .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 | [](https://www.codacy.com/manual/eliasjpr/azu?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/azu&utm_campaign=Badge_Grade) 
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 |
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 "#{name}>"
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 | {{error}}
121 | {% endfor %}
122 |
123 |
Back to homepage
124 |
125 |
126 |
127 |
128 |
129 |
--------------------------------------------------------------------------------