├── .gitignore ├── .travis.yml ├── LICENSE ├── Projectfile ├── README.md ├── shard.lock ├── shard.yml ├── spec ├── app │ └── views │ │ ├── flash.ecr │ │ ├── layout.ecr │ │ ├── test.ecr │ │ └── test_data.ecr ├── base_spec.cr ├── cache_spec.cr ├── controller_spec.cr ├── response_spec.cr ├── route_spec.cr ├── sessions_spec.cr ├── spec_helper.cr └── view │ ├── base_view_spec.cr │ └── view_tag_spec.cr └── src ├── amatista.cr └── amatista ├── base.cr ├── controller.cr ├── filter.cr ├── flash.cr ├── handler.cr ├── helpers.cr ├── model.cr ├── response.cr ├── route.cr ├── sessions.cr ├── version.cr └── view ├── base_view.cr ├── view_helpers.cr └── view_tag.cr /.gitignore: -------------------------------------------------------------------------------- 1 | .crystal/ 2 | .deps/* 3 | .deps.lock 4 | libs/* 5 | .shards/* 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | 3 | before_install: | 4 | mkdir -p .bin 5 | curl -L https://github.com/ysbaddaden/shards/releases/download/v0.4.0/shards-0.4.0_linux_amd64.tar.gz | tar xz -C .bin 6 | export PATH=".bin:$PATH" 7 | 8 | branches: 9 | only: 10 | - master 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Werner 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 | -------------------------------------------------------------------------------- /Projectfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/werner/amatista/739a2a3d15d9cc0d1b8350472620a2e1ca50e1bc/Projectfile -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amatista [![Build Status](https://travis-ci.org/werner/amatista.png)](https://travis-ci.org/werner/amatista) [![docrystal.org](http://www.docrystal.org/badge.svg?style=round)](http://www.docrystal.org/github.com/werner/amatista) 2 | 3 | # Deprecated!. 4 | ## you want to use [Kemal](https://github.com/kemalcr/kemal) instead 5 | 6 | This is a web framework build in [Crystal](https://github.com/manastech/crystal) to create quick applications. 7 | 8 | ### Shard file 9 | 10 | shard.yml 11 | ```yml 12 | name: myapp 13 | version: 0.0.1 14 | 15 | dependencies: 16 | amatista: 17 | github: werner/amatista 18 | ``` 19 | 20 | ### Basic Usage 21 | 22 | ```crystal 23 | require "amatista" 24 | 25 | class HelloWorldController < Amatista::Controller 26 | get "/" do 27 | html = %(

Hello World

) 28 | respond_to(:html, html) 29 | end 30 | end 31 | 32 | class Main < Amatista::Base 33 | configure do |conf| 34 | conf[:secret_key] = "secret" 35 | end 36 | end 37 | 38 | app = Main.new 39 | 40 | app.run 3000 41 | ``` 42 | 43 | ### Callbacks 44 | ```crystal 45 | 46 | class ApplicationController < Amatista::Controller 47 | #It will be a redirection if the condition is fulfilled, 48 | #it should not be a session with a key user_id for the redirect to works 49 | before_filter(condition: -> { !get_session("user_id") }) do 50 | redirect_to("/sessions/new") 51 | end 52 | end 53 | ``` 54 | 55 | ### View System 56 | 57 | ```crystal 58 | 59 | class HelloWorldController < Amatista::Controller 60 | get "/tasks" do 61 | tasks = Task.all 62 | # You're going to need a LayoutView class as 63 | # a layout for set_view method to work 64 | respond_to(:html, IndexView.new(tasks).set_view) 65 | end 66 | 67 | get "/tasks.json" do 68 | tasks = Task.all 69 | respond_to(:json, tasks.to_s.to_json) 70 | end 71 | end 72 | 73 | class LayoutView < Amatista::BaseView 74 | def initialize(@include) 75 | end 76 | 77 | set_ecr "layout" 78 | end 79 | 80 | class IndexView < Amatista::BaseView 81 | def initialize(@tasks) 82 | end 83 | 84 | def tasks_count 85 | @tasks.count 86 | end 87 | 88 | set_ecr "index" 89 | end 90 | 91 | #Views: 92 | #layout.ecr 93 | 94 | 95 | Todo App 96 | 97 | 98 | 99 | 100 |
101 |
102 |
103 | <%= @include %> 104 |
105 |
106 |
107 | 108 | 109 | 110 | 111 | 112 | 113 | #index.ecr 114 |
115 |
116 |

Todo Tasks

117 |
118 | 119 |
120 | 121 | 122 | <% @tasks.each do |task| %> 123 | 124 | 128 | 131 | 134 | 135 | <% end %> 136 | 137 |
125 | <%= check_box_tag(:task, "id#{task[0]}", task[0], task[2], { class: "checkTask" }) %> 126 | <%= label_tag("task_id#{task[0]}", task[1].to_s) %> 127 | 129 | <%= link_to("Edit", "/tasks/edit/#{task[0]}", { class: "btn btn-success btn-xs" }) %> 130 | 132 | <%= link_to("Delete", "/tasks/delete/#{task[0]}", { class: "del btn btn-danger btn-xs" }) %> 133 |
138 | <%= link_to("New Task", "/tasks/new", { class: "btn btn-info btn-xs" } ) %> 139 | <%= label_tag("total", "Total: #{tasks_count}" ) %> 140 |
141 |
142 | 143 | ``` 144 | ##### [Example](https://github.com/werner/todo_crystal) 145 | 146 | ## Contributing 147 | 148 | 1. Fork it ( https://github.com/werner/amatista/fork ) 149 | 2. Create your feature branch (git checkout -b my-new-feature) 150 | 3. Commit your changes (git commit -am 'Add some feature') 151 | 4. Push to the branch (git push origin my-new-feature) 152 | 5. Create a new Pull Request 153 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | mime: 4 | github: spalger/crystal-mime 5 | version: HEAD 6 | 7 | webmock: 8 | github: manastech/webmock.cr 9 | version: 0.4.0 10 | 11 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: amatista 2 | version: 0.4.1 3 | 4 | dependencies: 5 | mime: 6 | github: werner/crystal-mime 7 | 8 | development_dependencies: 9 | webmock: 10 | github: manastech/webmock.cr 11 | 12 | license: MIT 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/app/views/flash.ecr: -------------------------------------------------------------------------------- 1 | <%= get_flash(:message) %> 2 | -------------------------------------------------------------------------------- /spec/app/views/layout.ecr: -------------------------------------------------------------------------------- 1 | <%= @container %> 2 | -------------------------------------------------------------------------------- /spec/app/views/test.ecr: -------------------------------------------------------------------------------- 1 |

Hello World Test

2 | -------------------------------------------------------------------------------- /spec/app/views/test_data.ecr: -------------------------------------------------------------------------------- 1 |

Hello World Test

tasks: <%= @tasks %>
2 | -------------------------------------------------------------------------------- /spec/base_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class TestProcessController < Controller 4 | get("tests") { respond_to(:text, "Hello Tests") } 5 | end 6 | 7 | describe Base do 8 | app = Base.new 9 | headers = HTTP::Headers.new 10 | headers["Host"] = "host.domain.com" 11 | headers["Body"] = "" 12 | 13 | context "#process_request" do 14 | it "receive an http request" do 15 | request = HTTP::Request.new "GET", "/", headers 16 | 17 | response = app.process_request(request) 18 | 19 | response.class.should eq(HTTP::Response) 20 | end 21 | 22 | it "process a route with no slashes" do 23 | request = HTTP::Request.new "GET", "/tests", headers 24 | 25 | response = app.process_request(request) 26 | 27 | response.status_code.should eq(200) 28 | end 29 | end 30 | 31 | context "#process_static" do 32 | it "process a js file" do 33 | request = HTTP::Request.new "GET", "/", headers 34 | 35 | filename = "jquery.js" 36 | File.open(filename, "w") { |f| f.puts "jquery" } 37 | 38 | content = app.process_static("jquery.js") 39 | 40 | content.body.should eq("jquery\n") if content 41 | 42 | File.delete(filename) 43 | end 44 | 45 | it "does not process file" do 46 | request = HTTP::Request.new "GET", "/", headers 47 | route = app.process_static("jquery.js") 48 | 49 | route.should be_nil 50 | end 51 | 52 | it "process a path and returns nil" do 53 | request = HTTP::Request.new "GET", "/", headers 54 | route = app.process_static("/") 55 | 56 | route.should be_nil 57 | end 58 | 59 | it "generates a cached respond" do 60 | $amatista.environment = :production 61 | filename = "jquery.js" 62 | File.open(filename, "w") { |f| f.puts "jquery" } 63 | response = app.process_static("jquery.js") 64 | response.headers["Cache-control"].should_not be_nil if response.is_a? HTTP::Response 65 | File.delete(filename) 66 | end 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /spec/cache_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class TestCacheController < Controller 4 | end 5 | 6 | describe Helpers do 7 | subject = TestCacheController 8 | 9 | it "sets cache control" do 10 | subject.get("/tests") do 11 | subject.add_cache_control 12 | response = subject.respond_to(:text, "Hello Tests") 13 | end.headers["Cache-control"].should eq("public, max-age=31536000") 14 | end 15 | 16 | it "sets last modified" do 17 | subject.get("/tests") do 18 | filename = "for_cache" 19 | File.open(filename, "w") { |f| f.puts "Today" } 20 | subject.add_last_modified(filename) 21 | File.delete(filename) 22 | response = subject.respond_to(:text, "Hello Tests") 23 | end.headers["Last-modified"].should match(/\d{4}-\d{2}-\d{2}.* UTC/) 24 | end 25 | 26 | it "sets etag" do 27 | subject.get("/tests") do 28 | filename = "for_cache" 29 | File.open(filename, "w") { |f| f.puts "Today" } 30 | subject.add_etag(filename) 31 | File.delete(filename) 32 | response = subject.respond_to(:text, "Hello Tests") 33 | end.headers["etag"].size.should eq(32) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class LayoutView < BaseView 4 | def initialize(@container) 5 | end 6 | 7 | set_ecr("layout", "spec/app/views") 8 | end 9 | 10 | class DataTestView < BaseView 11 | def initialize(@tasks) 12 | end 13 | 14 | set_ecr("test_data", "spec/app/views") 15 | end 16 | 17 | class FlashView < BaseView 18 | def initialize() 19 | end 20 | 21 | set_ecr("flash", "spec/app/views") 22 | end 23 | 24 | class ApplicationController < Controller 25 | before_filter { set_session("test_filter", "testing a filter") } 26 | before_filter(["/tasks", "/users"]) { 27 | set_session("test_filter_with_paths", "testing a filter with paths") 28 | } 29 | end 30 | 31 | class TestController < ApplicationController 32 | end 33 | 34 | class FinishController < Controller 35 | before_filter(condition: -> { !get_session("condition") }) { redirect_to("/filter_tasks") } 36 | 37 | get("/filter_tests") { respond_to(:text, "Hello Home") } 38 | get("/filter_tasks") { respond_to(:text, "Hello Tasks") } 39 | end 40 | 41 | describe Controller do 42 | subject = TestController 43 | 44 | it "gets a get request" do 45 | html_result = "Hello World" 46 | subject.get("/") { subject.respond_to(:html, html_result) }.body.should( 47 | eq("Hello World") 48 | ) 49 | end 50 | 51 | it "gets a request based on a view" do 52 | subject.get("/") do 53 | tasks = ["first task", "second task"] 54 | subject.respond_to(:html, DataTestView.new(tasks).set_view) 55 | end.body.should(eq( 56 | "

Hello World Test

" + 57 | " tasks: [\"first task\", \"second task\"]
\n" 58 | )) 59 | end 60 | 61 | context "filters" do 62 | subject.send_sessions_to_cookie 63 | 64 | headers = HTTP::Headers.new 65 | headers["Cookie"] = "_amatista_session_id=#{$amatista.cookie_hash};" 66 | it "sets a global filter" do 67 | $amatista.request = HTTP::Request.new "GET", "/", headers 68 | $amatista.secret_key = "secret" 69 | 70 | subject.get("/") { subject.respond_to(:text, "Hello World") } 71 | Base.new.process_request(HTTP::Request.new("GET", "/", headers)) 72 | subject.get_session("test_filter").should eq("testing a filter") 73 | subject.get_session("test_filter_with_paths").should eq(nil) 74 | end 75 | 76 | it "sets a filter for certain paths" do 77 | $amatista.request = HTTP::Request.new "GET", "/tasks", headers 78 | $amatista.secret_key = "secret" 79 | 80 | subject.get("/tasks") { subject.respond_to(:text, "Hello Tasks") } 81 | Base.new.process_request(HTTP::Request.new("GET", "/tasks", headers)) 82 | subject.get_session("test_filter_with_paths").should eq("testing a filter with paths") 83 | end 84 | 85 | it "redirects from the filter" do 86 | $amatista.request = HTTP::Request.new "GET", "/tasks", headers 87 | $amatista.secret_key = "secret" 88 | 89 | Base.new.process_request(HTTP::Request.new("GET", "/filter_tests", headers)).body.should( 90 | eq("redirection") 91 | ) 92 | end 93 | 94 | it "does not redirects from the filter" do 95 | $amatista.request = HTTP::Request.new "GET", "/tasks", headers 96 | $amatista.secret_key = "secret" 97 | 98 | subject.set_session("condition", "true") 99 | Base.new.process_request(HTTP::Request.new("GET", "/filter_tests", headers)).body.should( 100 | eq("Hello Home") 101 | ) 102 | end 103 | end 104 | 105 | context "flash" do 106 | it "shows the flash message" do 107 | subject.get("/with_flash_message") do 108 | subject.set_flash(:message, "hello message") 109 | subject.respond_to(:html, FlashView.new.set_view) 110 | end.body.should(eq("hello message\n")) 111 | end 112 | 113 | it "does not show the flash message" do 114 | subject.get("/without_flash_message") do 115 | subject.respond_to(:html, FlashView.new.set_view) 116 | end.body.should(eq("\n")) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/response_spec.cr: -------------------------------------------------------------------------------- 1 | require "webmock" 2 | require "./spec_helper" 3 | 4 | describe Response do 5 | WebMock.stub(:any, "test") 6 | http_response = HTTP::Client.get("http://test") 7 | 8 | context ".find_route" do 9 | it "find path /tasks/new from a group of routes" do 10 | routes = [Route.new(nil, "GET", "/tasks/new", ->(x : Handler::Params){ http_response })] 11 | 50.times do |n| 12 | routes << Route.new(nil, "GET", "/tasks/#{n}", ->(x : Handler::Params){ http_response }) 13 | end 14 | 15 | route = Response.find_route(routes, "GET", "/tasks/new") 16 | 17 | route.path.should eq("/tasks/new") if route 18 | end 19 | 20 | it "does not find path" do 21 | routes = [Route.new(nil, "GET", "/tasks/new", ->(x : Handler::Params){ http_response })] 22 | 50.times do |n| 23 | routes << Route.new(nil, "GET", "/tasks/#{n}", ->(x : Handler::Params){ http_response }) 24 | end 25 | 26 | route = Response.find_route(routes, "GET", "/tasks/edit") 27 | 28 | route.should be_nil 29 | end 30 | 31 | it "find the GET method route" do 32 | routes = [Route.new(nil, "GET", "/tasks", ->(x : Handler::Params){ http_response }), 33 | Route.new(nil, "PUT", "/tasks", ->(x : Handler::Params){ http_response }), 34 | Route.new(nil, "POST", "/tasks", ->(x : Handler::Params){ http_response })] 35 | 36 | 37 | route = Response.find_route(routes, "GET", "/tasks") 38 | 39 | if route 40 | route.method.should eq("GET") 41 | route.path.should eq("/tasks") 42 | end 43 | end 44 | 45 | it "find the POST method route" do 46 | routes = [Route.new(nil, "GET", "/tasks", ->(x : Handler::Params){ http_response }), 47 | Route.new(nil, "POST", "/tasks", ->(x : Handler::Params){ http_response }), 48 | Route.new(nil, "DELETE", "/tasks", ->(x : Handler::Params){ http_response })] 49 | 50 | route = Response.find_route(routes, "POST", "/tasks") 51 | 52 | if route 53 | route.method.should eq("POST") 54 | route.path.should eq("/tasks") 55 | end 56 | end 57 | end 58 | 59 | context "#process_params" do 60 | headers = HTTP::Headers.new 61 | headers["Host"] = "host.domain.com" 62 | it "process params from path" do 63 | headers["Body"] = "" 64 | 65 | request = HTTP::Request.new "GET", "/tasks/edit/2/soon/34", headers 66 | response = Response.new(request) 67 | route = Route.new(nil, "GET", 68 | "/tasks/edit/:id/soon/:other_task", ->(x : Handler::Params){ http_response }) 69 | route.request_path = "/tasks/edit/2/soon/34" 70 | 71 | response.process_params(route).should eq({"id" => "2", "other_task" => "34"}) 72 | end 73 | 74 | it "process params from body request" do 75 | headers["Body"] = "" 76 | body = "task%5Bname%5D=hi&task%5Bdescription%5D=salute&commit=Create" 77 | request = HTTP::Request.new "POST", "/tasks/create", headers, body 78 | response = Response.new(request) 79 | route = Route.new(nil, "POST", "/tasks/create", ->(x : Handler::Params){ http_response }) 80 | route.request_path = "/tasks/create" 81 | 82 | response.process_params(route).should eq({"task" => {"name" => "hi", "description" => "salute"}, 83 | "commit" => "Create"}) 84 | end 85 | 86 | it "process params with checkboxes group from body request" do 87 | headers["Body"] = "" 88 | body = "task%5Bname%5D=hi&task%5Bdescription%5D=salute&task%5Btaxonomy_ids%5D=2&task%5Btaxonomy_ids%5D=3&commit=Create" 89 | request = HTTP::Request.new "POST", "/tasks/create", headers, body 90 | response = Response.new(request) 91 | route = Route.new(nil, "POST", "/tasks/create", ->(x : Handler::Params){ http_response }) 92 | route.request_path = "/tasks/create" 93 | 94 | response.process_params(route).should eq({"task" => {"name" => "hi", 95 | "description" => "salute", 96 | "taxonomy_ids" => ["2", "3"]}, 97 | "commit" => "Create"}) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/route_spec.cr: -------------------------------------------------------------------------------- 1 | require "webmock" 2 | require "./spec_helper" 3 | 4 | describe Route do 5 | WebMock.stub(:any, "test") 6 | http_response = HTTP::Client.get("http://test") 7 | 8 | context "#match_path?" do 9 | it "match the root path to true" do 10 | route = Route.new(nil, "GET", "/", ->(params : Handler::Params) { http_response }) 11 | 12 | route.match_path?("/").should eq(true) 13 | end 14 | 15 | it "match /tasks/edit/:id with /tasks/edit/2" do 16 | route = Route.new(nil, "GET", "/tasks/edit/:id", ->(params : Handler::Params) { http_response }) 17 | 18 | route.match_path?("/tasks/edit/2").should eq(true) 19 | end 20 | 21 | it "match /tasks/edit/:id with /tasks/edit/2/" do 22 | route = Route.new(nil, "GET", "/tasks/edit/:id", ->(params : Handler::Params) { http_response }) 23 | 24 | route.match_path?("/tasks/edit/2/").should eq(true) 25 | end 26 | 27 | it "does not match /tasks/edit/:id with /tasks/edit/2" do 28 | route = Route.new(nil, "GET", "/tasks/edit/:id", ->(params : Handler::Params) { http_response }) 29 | 30 | route.match_path?("/tasks/edit/2/show").should eq(false) 31 | end 32 | end 33 | 34 | context "#get_params" do 35 | it "extracts params from /tasks/edit/:id" do 36 | route = Route.new(nil, "GET", "/tasks/edit/:id", ->(params : Handler::Params) { http_response }) 37 | 38 | route.request_path = "/tasks/edit/2" 39 | params = {} of String => Handler::ParamsValue 40 | params = {"task" => {"description" => "some description", "taxonomy_ids" => ["1", "2"]}} 41 | route.add_params(params) 42 | 43 | route.get_params.should eq({"task" => {"description" => "some description", 44 | "taxonomy_ids" => ["1", "2"]}, "id" => "2"}) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/sessions_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Sessions do 4 | app = Controller 5 | app.send_sessions_to_cookie 6 | 7 | $amatista.secret_key = "secret" 8 | headers = HTTP::Headers.new 9 | headers["Cookie"] = "_amatista_session_id=#{$amatista.cookie_hash};" 10 | $amatista.request = HTTP::Request.new "GET", "/", headers 11 | 12 | context "#set_session" do 13 | it "creates a session variable" do 14 | app.set_session("test", "testing a session variable") 15 | $amatista.sessions[$amatista.cookie_hash].should( 16 | eq({"test" => "testing a session variable"}) 17 | ) 18 | end 19 | end 20 | 21 | context "#get_session" do 22 | it "returns a session variable" do 23 | app.set_session("test", "testing a session variable") 24 | app.get_session("test").should eq("testing a session variable") 25 | end 26 | end 27 | 28 | context "#remove_session" do 29 | it "removes a session variable" do 30 | app.set_session("test", "testing a session variable") 31 | app.get_session("test").should eq("testing a session variable") 32 | app.remove_session("test") 33 | app.get_session("test").should eq(nil) 34 | end 35 | end 36 | 37 | context "#has_session?" do 38 | it "returns true if session variable exists" do 39 | app.has_session?.should eq(true) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/amatista" 3 | 4 | include Amatista 5 | -------------------------------------------------------------------------------- /spec/view/base_view_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | class LayoutView < BaseView 4 | def initialize(@container) 5 | end 6 | 7 | set_ecr("layout", "spec/app/views") 8 | end 9 | 10 | class TestView < BaseView 11 | set_ecr("test", "spec/app/views") 12 | end 13 | 14 | describe BaseView do 15 | context "#view" do 16 | it "display a view file" do 17 | view = TestView 18 | 19 | view.new([1,2,3]).set_view.should eq("

Hello World Test

\n") 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/view/view_tag_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe ViewTag do 4 | context "#text_field" do 5 | it "display an input text" do 6 | view = BaseView.new 7 | 8 | view.text_field(:post, :title, { size: 20, class: "test_class" }).should( 9 | eq("") 10 | ) 11 | view.text_field(:post, :title).should eq("") 12 | end 13 | 14 | it "display an input password" do 15 | view = BaseView.new 16 | 17 | view.password_field(:post, :title, { size: 20, class: "test_class" }).should( 18 | eq("") 19 | ) 20 | view.password_field(:post, :title).should eq("") 21 | end 22 | 23 | it "display a hidden field" do 24 | view = BaseView.new 25 | 26 | view.hidden_tag(:post, :id, 1).should( 27 | eq("") 28 | ) 29 | end 30 | 31 | it "display a submit button" do 32 | view = BaseView.new 33 | 34 | view.submit_tag.should eq("") 35 | end 36 | 37 | it "display a form tag" do 38 | view = BaseView.new 39 | 40 | view.form_tag("/posts") do |form| 41 | form << view.text_field(:post, :title) 42 | form << view.text_field(:post, :name) 43 | form << view.submit_tag("Save changes") 44 | end.should eq("
" + 45 | "" + 46 | "" + 47 | "" + 48 | "
") 49 | end 50 | 51 | it "display a paragraph content tag" do 52 | view = BaseView.new 53 | 54 | view.content_tag(:p, "Hello world!").should eq("

Hello world!

") 55 | end 56 | 57 | it "display an input text inside a div content tag" do 58 | view = BaseView.new 59 | 60 | view.content_tag(:div, view.text_field(:post, :title), 61 | { class: "form-control" }).should( 62 | eq("
") 63 | ) 64 | end 65 | 66 | it "display a link tag" do 67 | view = BaseView.new 68 | 69 | view.link_to("Profile", "/profiles/1").should eq("Profile") 70 | end 71 | 72 | it "display a label tag" do 73 | view = BaseView.new 74 | 75 | view.label_tag("name", "Name").should eq("") 76 | end 77 | 78 | it "display a checkbox tag" do 79 | view = BaseView.new 80 | 81 | view.check_box_tag("task", "accept", "0", true).should( 82 | eq("") 83 | ) 84 | end 85 | 86 | it "display a radio button tag" do 87 | view = BaseView.new 88 | 89 | view.radio_button_tag("task", "accept", "0", true).should( 90 | eq("") 91 | ) 92 | end 93 | 94 | it "display a select tag" do 95 | view = BaseView.new 96 | 97 | view.select_tag("task", "countries", [["1", "USA"], ["2", "CANADA"], ["3", "VENEZUELA"]]).should( 98 | eq("") 103 | ) 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /src/amatista.cr: -------------------------------------------------------------------------------- 1 | require "./amatista/**" 2 | -------------------------------------------------------------------------------- /src/amatista/base.cr: -------------------------------------------------------------------------------- 1 | require "./helpers" 2 | require "./sessions" 3 | 4 | module Amatista 5 | # This class is used as a base for running amatista apps. 6 | class Base 7 | include Helpers 8 | include Sessions 9 | 10 | # Saves the configure options in a global variable 11 | # 12 | # Example: 13 | # ```crystal 14 | # class Main < Amatista::Base 15 | # configure do |conf| 16 | # conf[:secret_key] = "secret" 17 | # conf[:database_driver] = "postgres" 18 | # conf[:database_connection] = ENV["DATABASE_URL"] 19 | # end 20 | # end 21 | # ``` 22 | def self.configure 23 | configuration = {} of Symbol => (String | Bool) 24 | yield(configuration) 25 | $amatista.secret_key = configuration[:secret_key]?.to_s 26 | $amatista.database_connection = configuration[:database_connection]?.to_s 27 | $amatista.database_driver = configuration[:database_driver]?.to_s 28 | public_dir = configuration[:public_dir]?.to_s 29 | $amatista.public_dir = public_dir unless public_dir.empty? 30 | @@logs = configuration[:logs]? || false 31 | end 32 | 33 | # Run the server, just needs a port number. 34 | def run(port, environment = :development) 35 | $amatista.environment = environment 36 | server = create_server(port) 37 | server.listen 38 | end 39 | 40 | def run_forked(port, environment = :development, workers = 8) 41 | $amatista.environment = environment 42 | server = create_server(port) 43 | server.listen_fork(workers) 44 | end 45 | 46 | # Process static file 47 | def process_static(path) 48 | file = File.join($amatista.public_dir, path) 49 | if File.file?(file) 50 | if $amatista.environment == :production 51 | add_cache_control 52 | add_last_modified(file) 53 | end 54 | respond_to(File.extname(path).gsub(".", ""), File.read(file)) 55 | end 56 | end 57 | 58 | # Returns a response based on the request client. 59 | def process_request(request : HTTP::Request) : HTTP::Response 60 | begin 61 | response = Response.new(request) 62 | $amatista.request = request 63 | route = Response.find_route($amatista.routes, request.method, request.path.to_s) 64 | return HTTP::Response.not_found unless route 65 | 66 | response_filter = Filter.find_response($amatista.filters, route.controller, route.path) 67 | return response_filter.try &.call() if response_filter.is_a?(-> HTTP::Response) 68 | 69 | Filter.execute_blocks($amatista.filters, route.controller, route.path) 70 | 71 | $amatista.params = response.process_params(route) 72 | route.block.call($amatista.params) 73 | rescue e 74 | HTTP::Response.error "text/plain", "Error: #{e}" 75 | end 76 | end 77 | 78 | private def create_server(port) 79 | HTTP::Server.new port, do |request| 80 | p request if @@logs 81 | static_response = process_static(request.path.to_s) 82 | return static_response if static_response.is_a? HTTP::Response 83 | process_request(request) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/amatista/controller.cr: -------------------------------------------------------------------------------- 1 | require "./flash" 2 | require "./helpers" 3 | require "./sessions" 4 | 5 | module Amatista 6 | class Controller 7 | extend Helpers 8 | extend Sessions 9 | extend Flash 10 | 11 | macro inherited 12 | # Creates 5 methods to handle the http requests from the browser. 13 | {% for method in %w(get post put delete patch) %} 14 | def self.{{method.id}}(path : String, &block : Amatista::Handler::Params -> HTTP::Response) 15 | $amatista.routes << Amatista::Route.new({{@type}}, "{{method.id}}".upcase, path, block) 16 | yield($amatista.params) 17 | end 18 | {% end %} 19 | 20 | def self.before_filter(paths = [] of String, condition = -> { false }, &block : -> T) 21 | $amatista.filters << Amatista::Filter.new({{@type}}, paths, condition, block) 22 | end 23 | 24 | def self.superclass 25 | {{@type.superclass}} 26 | end 27 | end 28 | 29 | def self.superclass 30 | self 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/amatista/filter.cr: -------------------------------------------------------------------------------- 1 | module Amatista 2 | #Callback filters to use before the actions. 3 | class Filter 4 | property block 5 | property paths 6 | property controller 7 | property condition 8 | 9 | def initialize(@controller, @paths, @condition, @block : T) 10 | end 11 | 12 | # Finds the filters based on the controller or the ApplicationController father 13 | # and the paths selected. 14 | def self.find_all(filters, controller, path) 15 | filters.select do |filter| 16 | check_controller(filter.controller, controller) && 17 | (filter.paths.includes?(path) || filter.paths.empty?) 18 | end 19 | end 20 | 21 | # This will search for the filters callbacks and execute the block 22 | #if it's not an HTTP::Response 23 | def self.execute_blocks(filters, controller, path) 24 | filters.each do |filter| 25 | if check_controller(filter.controller, controller) && 26 | (filter.paths.includes?(path) || filter.paths.empty?) && 27 | !filter.block.is_a?(-> HTTP::Response) 28 | filter.block.call() 29 | end 30 | end 31 | end 32 | 33 | #Find a filter that has a block as an HTTP::Response return's value 34 | def self.find_response(filters, controller, path) 35 | filters.each do |filter| 36 | block = filter.block 37 | if check_controller(filter.controller, controller) && 38 | (filter.paths.includes?(path) || filter.paths.empty?) && 39 | (filter.block.is_a?(-> HTTP::Response) && filter.condition.call()) 40 | return block 41 | end 42 | end 43 | end 44 | 45 | private def self.check_controller(filter_controller, controller) 46 | (filter_controller == controller || 47 | filter_controller == controller.try(&.superclass)) 48 | end 49 | 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /src/amatista/flash.cr: -------------------------------------------------------------------------------- 1 | module Amatista 2 | #Flash sessions used until it is called. 3 | module Flash 4 | def set_flash(key, value) 5 | $amatista.flash[key] = value 6 | end 7 | 8 | def get_flash(key) 9 | flash_value = $amatista.flash[key]? 10 | $amatista.flash.delete(key) 11 | flash_value 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/amatista/handler.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | require "./route" 3 | 4 | module Amatista 5 | # Use to saves configuration, routes and other data needed for the application. 6 | class Handler 7 | alias ParamsValue = Hash(String, String | Array(String)) | String | Array(String) 8 | alias Params = Hash(String, ParamsValue) 9 | property params 10 | property routes 11 | property filters 12 | property sessions 13 | property cookie_hash 14 | property secret_key 15 | property request 16 | property database_connection 17 | property database_driver 18 | property public_dir 19 | property flash 20 | property environment 21 | 22 | def initialize 23 | @params = {} of String => ParamsValue 24 | @routes = [] of Route 25 | @filters = [] of Filter 26 | @sessions = {} of String => Hash(String, String) 27 | @cookie_hash = "" 28 | @secret_key = "" 29 | @request = nil 30 | @database_connection = "" 31 | @database_driver = "" 32 | @public_dir = Dir.current 33 | @flash = {} of Symbol => String 34 | @environment = :development 35 | end 36 | end 37 | end 38 | 39 | $amatista = Amatista::Handler.new 40 | -------------------------------------------------------------------------------- /src/amatista/helpers.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | require "mime" 3 | require "crypto/md5" 4 | 5 | module Amatista 6 | # Helpers used by the methods in the controller class. 7 | module Helpers 8 | # Redirects to an url 9 | # 10 | # Example 11 | # ```crystal 12 | # redirect_to "/tasks" 13 | # ``` 14 | def redirect_to(path) 15 | add_headers :location, path 16 | HTTP::Response.new 303, "redirection", set_headers 17 | end 18 | 19 | # Makes a respond based on context type 20 | # The body argument should be string if used html context type 21 | def respond_to(context, body) 22 | context = Mime.from_ext(context).to_s 23 | add_headers :context, context 24 | HTTP::Response.new 200, body, set_headers 25 | end 26 | 27 | # Send data as attachment 28 | def send_data(body, filename, disposition="attachment") 29 | add_headers :disposition, "attachment; filename='#{filename}'" 30 | HTTP::Response.new 200, body, set_headers 31 | end 32 | 33 | def add_cache_control(max_age = 31536000, public = true) 34 | state = public ? "public" : "private" 35 | add_headers :cache, "#{state}, max-age=#{max_age}" 36 | end 37 | 38 | def add_last_modified(resource) 39 | add_headers :last_modified, File.stat(resource).mtime.to_s 40 | end 41 | 42 | def add_etag(resource) 43 | stat = File.stat(resource) 44 | to_encrypt = stat.ino + stat.size + stat.mtime.ticks 45 | add_headers :etag, Crypto::MD5.hex_digest(to_encrypt.to_s) 46 | end 47 | 48 | def set_headers 49 | headers = @@header 50 | @@header = HTTP::Headers.new 51 | headers || HTTP::Headers.new 52 | end 53 | 54 | private def add_headers(type, value) 55 | @@header = HTTP::Headers.new unless @@header 56 | 57 | header_label = {context: "Content-Type", 58 | location: "Location", 59 | cache: "Cache-Control", 60 | last_modified: "Last-Modified", 61 | disposition: "Content-Disposition", 62 | etag: "ETag"} 63 | 64 | header = @@header 65 | if header 66 | header.add(header_label[type], value) 67 | header.add("Set-Cookie", send_sessions_to_cookie) unless has_session? 68 | end 69 | @@header = header 70 | end 71 | 72 | # Find out the IP address 73 | def remote_ip 74 | return unless request = $amatista.request 75 | 76 | headers = %w(X-Forwarded-For Proxy-Client-IP WL-Proxy-Client-IP HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED 77 | HTTP_X_CLUSTER_CLIENT_IP HTTP_CLIENT_IP HTTP_FORWARDED_FOR HTTP_FORWARDED HTTP_VIA 78 | REMOTE_ADDR) 79 | 80 | headers.map{|header| request.headers[header]? as String | Nil}.compact.first 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/amatista/model.cr: -------------------------------------------------------------------------------- 1 | module Amatista 2 | class Model 3 | # Executes a query against a database. 4 | # Example 5 | # ```crystal 6 | # class Task < Amatista::Model 7 | # def self.all 8 | # records = [] of String 9 | # connect {|db| records = db.exec("select * from tasks order by done").rows } 10 | # records 11 | # end 12 | # end 13 | # ``` 14 | def self.connect 15 | if $amatista.database_driver == "postgres" 16 | db = PG::Connection.new($amatista.database_connection) 17 | yield(db) 18 | db.finish 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/amatista/response.cr: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "mime" 3 | 4 | module Amatista 5 | # Use by the framework to return an appropiate response based on the request. 6 | class Response 7 | property request 8 | 9 | def initialize(@request) 10 | end 11 | 12 | def self.find_route(routes, method, path_to_find) 13 | routes.find {|route_request| route_request.method == method && route_request.match_path?(path_to_find) } 14 | end 15 | 16 | def process_params(route) : Handler::Params 17 | route.request_path = @request.path.to_s 18 | route.add_params(objectify_params(@request.body.to_s)) 19 | route.get_params 20 | end 21 | 22 | #Convert params get from CGI to a Crystal Hash object 23 | private def objectify_params(raw_params) : Handler::Params 24 | result = {} of String => Handler::ParamsValue 25 | params = {} of String => Array(String) 26 | 27 | HTTP::Params.parse(raw_params) do |key, value| 28 | ary = params[key] ||= [] of String 29 | ary.push value 30 | end 31 | 32 | params.each do |key, value| 33 | object = key.match(/(\w*)\[(\w*)\]/) { |x| [x[1], x[2]] } 34 | if object.is_a?(Array(String)) 35 | name, method = object 36 | final_value = value.size > 1 ? value : value.first 37 | merge_same_key(result, name, method, final_value, result[name]?) 38 | elsif object.nil? 39 | result.merge!({key => value.first}) 40 | end 41 | end 42 | result 43 | end 44 | 45 | private def merge_same_key(result, name, method, value : String | Array(String), 46 | child : Handler::ParamsValue | Nil) 47 | 48 | case child 49 | when Hash(String, String | Array(String)) 50 | child.merge!({method => value}) 51 | else 52 | result.merge!({name => {method => value}}) 53 | end 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /src/amatista/route.cr: -------------------------------------------------------------------------------- 1 | module Amatista 2 | # Use by the framework to handle the request params. 3 | class Route 4 | property controller 5 | property method 6 | property path 7 | property block 8 | property request_path 9 | 10 | def initialize(@controller, @method, @path, @block) 11 | @params = {} of String => Handler::ParamsValue 12 | @request_path = "" 13 | end 14 | 15 | # Get personalized params from routes defined by user 16 | def get_params 17 | if @request_path == "" 18 | raise "You need to set params and request_path first" 19 | else 20 | extract_params_from_path 21 | @params 22 | end 23 | end 24 | 25 | # Search for similar paths 26 | # Example: /tasks/edit/:id == /tasks/edit/2 27 | def match_path?(path) 28 | return path == "/" if @path == "/" 29 | 30 | original_path = @path.split("/") - [""] 31 | path_to_match = path.split("/") - [""] 32 | 33 | original_path.size == path_to_match.size && 34 | original_path.zip(path_to_match).all? do |item| 35 | item[0].match(/(:\w*)/) ? true : item[0] == item[1] 36 | end 37 | end 38 | 39 | # Add personalized params to the coming from requests 40 | def add_params(params : Handler::Params) 41 | params.each do |key, value| 42 | @params[key] = value 43 | end 44 | end 45 | 46 | private def extract_params_from_path 47 | params = @path.to_s.scan(/(:\w*)/).map(&.[](0)) 48 | pairs = @path.split("/").zip(@request_path.split("/")) 49 | pairs.select{|pair| params.includes?(pair[0])}.each do |p| 50 | @params.merge!({p[0].gsub(/:/, "") => p[1]}) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/amatista/sessions.cr: -------------------------------------------------------------------------------- 1 | require "secure_random" 2 | 3 | module Amatista 4 | # Methods to save a sessions hash in a cookie. 5 | module Sessions 6 | 7 | def send_sessions_to_cookie 8 | return "" unless request = $amatista.request 9 | hash = SecureRandom.base64 10 | $amatista.cookie_hash = $amatista.sessions[hash]? ? hash : SecureRandom.base64 11 | "_amatista_session_id= #{$amatista.cookie_hash}" 12 | end 13 | 14 | # Saves a session key. 15 | def set_session(key, value) 16 | $amatista.cookie_hash = $amatista.cookie_hash.empty? ? get_cookie.to_s : $amatista.cookie_hash 17 | $amatista.sessions[$amatista.cookie_hash] = {key => value} 18 | end 19 | 20 | # Get a value from the cookie. 21 | def get_session(key) 22 | session_hash = get_cookie 23 | return nil unless $amatista.sessions[session_hash]? 24 | $amatista.sessions[session_hash][key]? if session_hash 25 | end 26 | 27 | # remove a sessions value from the cookie. 28 | def remove_session(key) 29 | session_hash = get_cookie 30 | return nil unless $amatista.sessions[session_hash]? 31 | $amatista.sessions[session_hash].delete(key) 32 | end 33 | 34 | def has_session? 35 | return unless request = $amatista.request 36 | cookie = request.headers["Cookie"]?.to_s 37 | !cookie.split(";").select(&.match(/_amatista_session_id/)).empty? 38 | end 39 | 40 | private def get_cookie 41 | return unless request = $amatista.request 42 | cookie = request.headers["Cookie"]?.to_s 43 | process_session(cookie) 44 | end 45 | 46 | private def process_session(string) 47 | amatista_session = string.split(";").select(&.match(/_amatista_session_id/)).first? 48 | amatista_session.gsub(/_amatista_session_id=/,"").gsub(/\s/,"") if amatista_session 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /src/amatista/version.cr: -------------------------------------------------------------------------------- 1 | module Amatista 2 | VERSION = "0.5.1" 3 | end 4 | -------------------------------------------------------------------------------- /src/amatista/view/base_view.cr: -------------------------------------------------------------------------------- 1 | require "ecr" 2 | require "ecr/macros" 3 | require "./view_tag" 4 | require "../sessions" 5 | require "../flash" 6 | 7 | module Amatista 8 | # Set of methods to reduce the steps to display a view 9 | # It needs a LayoutView class that works as a layout view. 10 | # The views should be placed in app/views folder. 11 | class BaseView 12 | include ViewTag 13 | include Sessions 14 | include Flash 15 | 16 | def initialize(@arguments = nil) 17 | end 18 | 19 | # compiles the view with data in a string format 20 | def set_view 21 | LayoutView.new(self.to_s).to_s.strip 22 | end 23 | 24 | macro set_ecr(view_name, path = "src/views") 25 | ecr_file "#{{{path}}}/#{{{view_name}}}.ecr" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/amatista/view/view_helpers.cr: -------------------------------------------------------------------------------- 1 | require "html" 2 | 3 | module Amatista 4 | # Set of helpers for the tag views 5 | module ViewHelpers 6 | private def options_transfomed(options = [] of Hash(Symbol, String)) 7 | options.map do |key, value| 8 | "#{key.to_s} = \"#{HTML.escape(value.to_s)}\"" 9 | end.join(" ") 10 | end 11 | 12 | private def extract_option_tags(collection) 13 | collection.inject("") do |acc, item| 14 | acc + 15 | surrounded_tag(:option, item[1], [] of Hash(Symbol, String)) do |str_result| 16 | str_result << " value =\"#{item[0]}\"" 17 | end 18 | end 19 | end 20 | 21 | private def input_tag(raw_options) 22 | options = options_transfomed(raw_options) 23 | str_result = MemoryIO.new 24 | yield(str_result) 25 | str_result << " #{options}" unless options.empty? 26 | str_result << " />" 27 | str_result.to_s 28 | end 29 | 30 | private def surrounded_tag(tag, value, raw_options) 31 | options = options_transfomed(raw_options) 32 | str_result = MemoryIO.new 33 | str_result << "<#{tag.to_s}" 34 | yield(str_result) 35 | str_result << " #{options}" unless options.empty? 36 | str_result << ">" 37 | str_result << value 38 | str_result << "" 39 | str_result.to_s 40 | end 41 | 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /src/amatista/view/view_tag.cr: -------------------------------------------------------------------------------- 1 | require "./view_helpers" 2 | 3 | module Amatista 4 | # Methods to create html tags using crystal 5 | module ViewTag 6 | include ViewHelpers 7 | 8 | def text_field(object_name, method, raw_options = [] of Hash(Symbol, String)) 9 | input_tag(raw_options) do |str_result| 10 | str_result << "" 72 | str_body_result = MemoryIO.new 73 | str_result << yield(str_body_result) 74 | str_result << "" 75 | str_result.to_s 76 | end 77 | 78 | def select_tag(object_name, method, collection, raw_options = [] of Hash(Symbol, String)) 79 | option_tags = extract_option_tags(collection) 80 | surrounded_tag(:select, option_tags, raw_options) do |str_result| 81 | str_result << " id=\"#{HTML.escape(object_name.to_s)}_#{HTML.escape(method.to_s)}\"" 82 | str_result << " name=\"#{HTML.escape(object_name.to_s)}[#{HTML.escape(method.to_s)}]\" " 83 | end 84 | end 85 | 86 | def content_tag(tag, value, raw_options = [] of Hash(Symbol, String)) 87 | surrounded_tag(tag, value, raw_options) {} 88 | end 89 | end 90 | end 91 | --------------------------------------------------------------------------------