diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index cbe5f8c6..80b3b122 100755 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -9,17 +9,14 @@ jobs: deploy_docs: runs-on: ubuntu-latest container: - image: crystallang/crystal:latest-alpine + image: crystallang/crystal steps: - uses: actions/checkout@v2 - - name: Install Dependencies - run: shards install --production - name: Build - run: make docs + run: crystal docs - name: Deploy - uses: JamesIves/github-pages-deploy-action@3.5.2 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: JamesIves/github-pages-deploy-action@2.0.1 + env: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} BRANCH: gh-pages FOLDER: docs - SINGLE_COMMIT: true diff --git a/README.md b/README.md index e247cdc2..652df5ec 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,6 @@ A web framework comprised of reusable, independent components. -## Installation - -1. Add the dependency to your `shard.yml`: - -```yaml -dependencies: - athena: - github: athena-framework/athena -``` - -2. Run `shards install` - ## Documentation Everything is documented in the [API Docs](https://athena-framework.github.io/athena/Athena/Routing.html). diff --git a/shard.yml b/shard.yml index 51d1ba34..659dc7e0 100755 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: athena -version: 0.8.0 +version: 0.9.0 crystal: 0.35.0 @@ -25,7 +25,7 @@ dependencies: version: ~> 0.1.0 athena-dependency_injection: github: athena-framework/dependency-injection - branch: di-refactor-v2 + version: ~> 0.2.0 amber_router: github: amberframework/amber-router version: ~> 0.4.1 diff --git a/spec/redirect_response_spec.cr b/spec/redirect_response_spec.cr index 8f2eb41e..298b8536 100644 --- a/spec/redirect_response_spec.cr +++ b/spec/redirect_response_spec.cr @@ -7,7 +7,7 @@ describe ART::RedirectResponse do end it "disallows non redirect codes" do - expect_raises(ArgumentError, "422 is not an HTTP redirect status code.") do + expect_raises(ArgumentError, "'422' is not an HTTP redirect status code.") do ART::RedirectResponse.new("addresss", 422) end end diff --git a/src/annotations.cr b/src/annotations.cr index 8a944727..46e1b977 100644 --- a/src/annotations.cr +++ b/src/annotations.cr @@ -1,5 +1,5 @@ module Athena::Routing - # Defines a GET endpoint. + # Defines a `GET` endpoint. # # A corresponding `HEAD` endpoint is also defined. # @@ -15,7 +15,7 @@ module Athena::Routing # ``` annotation Get; end - # Defines a POST endpoint. + # Defines a `POST` endpoint. # # ## Fields # * path : `String` - The path for the endpoint, may also be provided as the first positional argument. @@ -29,7 +29,7 @@ module Athena::Routing # ``` annotation Post; end - # Defines a PUT endpoint. + # Defines a `PUT` endpoint. # # ## Fields # * path : `String` - The path for the endpoint, may also be provided as the first positional argument. @@ -43,7 +43,7 @@ module Athena::Routing # ``` annotation Put; end - # Defines a PATCH endpoint. + # Defines a `PATCH` endpoint. # # ## Fields # * path : `String` - The path for the endpoint, may also be provided as the first positional argument. @@ -57,7 +57,7 @@ module Athena::Routing # ``` annotation Patch; end - # Defines a DELETE endpoint. + # Defines a `DELETE` endpoint. # # ## Fields # * path : `String` - The path for the endpoint, may also be provided as the first positional argument. @@ -71,7 +71,7 @@ module Athena::Routing # ``` annotation Delete; end - # Applies an `ART::ParamConverterInterface` to a given parameter. + # Applies an `ART::ParamConverterInterface` to a given argument. # # See `ART::ParamConverterInterface` for more information on defining a param converter. # @@ -91,7 +91,7 @@ module Athena::Routing # ``` annotation ParamConverter; end - # Defines a `ART::Parameters::QueryParameter` tied to a given route. + # Defines a query parameter tied to a given argument. # # The type of the query param is derived from the type restriction of the associated controller action argument. # @@ -107,8 +107,8 @@ module Athena::Routing # # ``` # @[ART::Get(path: "/example")] - # @[ART::QueryParam(name: "value")] - # def get_user(name : String) : Nil + # @[ART::QueryParam("query_param")] + # def get_user(query_param : String) : Nil # end # ``` annotation QueryParam; end diff --git a/src/athena.cr b/src/athena.cr index 97cc6662..2519887b 100755 --- a/src/athena.cr +++ b/src/athena.cr @@ -36,36 +36,318 @@ require "./ext/request" # Convenience alias to make referencing `Athena::Routing` types easier. alias ART = Athena::Routing -# Athena's Routing component, `ART` for short, provides an event based framework for converting a request into a response -# and includes various abstractions/useful types to make that process easier. -# -# Athena is an event based framework; meaning it emits `ART::Events` that are acted upon to handle the request. -# Athena also utilizes `Athena::DependencyInjection` to provide a service container layer. The service container layer -# allows a project to share/inject useful objects between various types, such as a custom `AED::EventListenerInterface`, `ART::Controller`, or `ART::ParamConverterInterface`. -# See the corresponding types for more information. -# -# * See `ART::Controller` for documentation on defining controllers/route actions. -# * See `ART::Config` for documentation on configuration options available for the Routing component. -# * See `ART::Events` for documentation on the events that can be listened on during the request's life-cycle. -# * See `ART::ParamConverterInterface` for documentation on using param converters. -# * See `ART::Exceptions` for documentation on exception handling. +# Athena is a set of independent, reusable [components](https://github.com/athena-framework) with the goal of providing +# a set of high quality, flexible, and robust framework building blocks. These components could be used on their own, +# or outside of the Athena ecosystem, to prevent every framework/project from needing to "reinvent the wheel." +# +# The `Athena::Routing` component is the result of combining these components into a single robust, flexible, and self-contained framework. +# +# ## Getting Started +# +# Athena does not have any other dependencies outside of `Crystal`/`Shards`. +# It is designed in such a way to be non-intrusive, and not require a strict organizational convention in regards to how a project is setup; +# this allows it to use a minimal amount of setup boilerplate while not preventing it for more complex projects. +# +# ### Installation +# +# Add the dependency to your `shard.yml`: +# +# ```yaml +# dependencies: +# athena: +# github: athena-framework/athena +# version: 0.9.0 +# ``` +# +# Run `shards install`. This will install Athena and its required dependencies. +# +# ### Usage +# +# Athena has a goal of being easy to start using for simple use cases, while still allowing flexibility/customizability for larger more complex use cases. +# +# #### Routing +# +# Athena is a MVC based framework, as such, the logic to handle a given route is defined in an `ART::Controller` class. +# +# ``` +# require "athena" +# +# # Define a controller +# class ExampleController < ART::Controller +# # Define an action to handle the related route +# @[ART::Get("/")] +# def index : String +# "Hello World" +# end +# +# # The macro DSL can also be used +# get "/" do +# "Hello World" +# end +# end +# +# # Run the server +# ART.run +# +# # GET / # => Hello World +# ``` +# Annotations applied to the methods are used to define the HTTP method this method handles, such as `ART::Get` or `ART::Post`. A macro DSL also exists to make them a bit less verbose; +# `ART::Controller.get` or `ART::Controller.post`. +# +# Controllers are simply classes and routes are simply methods. Controllers and actions can be documented/tested as you would any Crystal class/method. +# +# #### Route Parameters +# +# Parameters, such as path/query parameters, are also defined via annotations and map directly to the method's arguments. +# +# ``` +# require "athena" +# +# class ExampleController < ART::Controller +# @[ART::QueryParam("negative")] +# @[ART::Get("/add/:value1/:value2")] +# def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32 +# sum = value1 + value2 +# negative ? -sum : sum +# end +# end +# +# ART.run +# +# # GET /add/2/3 # => 5 +# # GET /add/5/5?negative=true # => -10 +# # GET /add/foo/12 # => {"code":422,"message":"Required parameter 'value1' with value 'foo' could not be converted into a valid 'Int32'"} +# ``` +# +# Arguments are converted to their expected types if possible, otherwise an error response is automatically returned. +# The values are provided directly as method arguments, thus preventing the need for `env.params.url["name"]` and any boilerplate related to it. +# Just like normal methods arguments, default values can be defined. The method's return type adds some type safety to ensure the expected value is being returned. +# +# An `ART::Response` can also be used in order to fully customize the response, such as returning a specific status code, adding some one-off headers, or saving memory by directly +# writing the response value to the Response IO. +# +# ``` +# require "athena" +# require "mime" +# +# class ExampleController < ART::Controller +# # A GET endpoint returning an `ART::Response`. +# @[ART::Get(path: "/css")] +# def css : ART::Response +# ART::Response.new ".some_class { color: blue; }", headers: HTTP::Headers{"content-type" => MIME.from_extension(".css")} +# end +# end +# +# ART.run +# +# # GET /css # => ".some_class { color: blue; }" +# ``` +# +# An `ART::Events::View` is emitted if the returned value is _NOT_ an `ART::Response`. By default, non `ART::Response`s are JSON serialized. +# However, this event can be listened on to customize how the value is serialized. +# +# #### Error Handling +# +# Exception handling in Athena is similar to exception handling in any Crystal program, with the addition of a new unique exception type, `ART::Exceptions::HTTPException`. +# Custom `HTTP` errors can also be defined by inheriting from `ART::Exceptions::HTTPException` or a child type. +# A use case for this could be allowing additional data/context to be included within the exception. +# +# Non `ART::Exceptions::HTTPException` exceptions are represented as a `500 Internal Server Error`. +# +# When an exception is raised, Athena emits the `ART::Events::Exception` event to allow an opportunity for it to be handled. +# By default these exceptions will return a `JSON` serialized version of the exception, via `ART::ErrorRenderer`, that includes the message and code; with the proper response status set. +# If the exception goes unhandled, i.e. no listener set an `ART::Response` on the event, then the request is finished and the exception is reraised. +# +# ``` +# require "athena" +# +# class ExampleController < ART::Controller +# get "divide/:num1/:num2", num1 : Int32, num2 : Int32, return_type: Int32 do +# num1 // num2 +# end +# +# get "divide_rescued/:num1/:num2", num1 : Int32, num2 : Int32, return_type: Int32 do +# num1 // num2 +# # Rescue a non `ART::Exceptions::HTTPException` +# rescue ex : DivisionByZeroError +# # in order to raise an `ART::Exceptions::HTTPException` to provide a better error message to the client. +# raise ART::Exceptions::BadRequest.new "Invalid num2: Cannot divide by zero" +# end +# end +# +# ART.run +# +# # GET /divide/10/0 # => {"code":500,"message":"Internal Server Error"} +# # GET /divide_rescued/10/0 # => {"code":400,"message":"Invalid num2: Cannot divide by zero"} +# ``` +# +# ### Advanced Usage +# +# Athena also ships with some more advanced features to provide more flexibility/control for an application. +# These features may not be required for a simple application; however as the application grows they may become more useful. +# +# #### Param Converters +# `ART::ParamConverterInterface`s allow complex types to be supplied to an action via its arguments. +# An example of this could be extracting the id from `/users/10`, doing a DB query to lookup the user with the PK of `10`, then providing the full user object to the action. +# Param converters abstract any custom parameter handling that would otherwise have to be done in each action. +# +# ``` +# require "athena" +# +# @[ADI::Register] +# struct MultiplyConverter < ART::ParamConverterInterface +# # :inherit: +# def apply(request : HTTP::Request, configuration : Configuration) : Nil +# arg_name = configuration.name +# +# return unless request.attributes.has? arg_name +# +# value = request.attributes.get arg_name, Int32 +# request.attributes.set arg_name, value * 2, Int32 +# end +# end +# +# class ParamConverterController < ART::Controller +# @[ART::Get(path: "/multiply/:num")] +# @[ART::ParamConverter("num", converter: MultiplyConverter)] +# def multiply(num : Int32) : Int32 +# num +# end +# end +# +# ART.run +# +# # GET / multiply/3 # => 6 +# ``` +# +# #### Middleware +# +# Athena is an event based framework; meaning it emits `ART::Events` that are acted upon internally to handle the request. +# These same events can also be listened on by custom listeners, via `AED::EventListenerInterface`, in order to tap into the life-cycle of the request. +# An example use case of this could be: adding common headers, cookies, compressing the response, authentication, or even returning a response early like `ART::Listeners::CORS`. +# +# ``` +# require "athena" +# +# @[ADI::Register] +# struct CustomListener +# include AED::EventListenerInterface +# +# # Specify that we want to listen on the `Response` event. +# # The value of the hash represents this listener's priority; +# # the higher the value the sooner it gets executed. +# def self.subscribed_events : AED::SubscribedEvents +# AED::SubscribedEvents{ +# ART::Events::Response => 25, +# } +# end +# +# def call(event : ART::Events::Response, dispatcher : AED::EventDispatcherInterface) : Nil +# event.response.headers["FOO"] = "BAR" +# end +# end +# +# class ExampleController < ART::Controller +# get "/" do +# "Hello World" +# end +# end +# +# ART.run +# +# # GET / # => Hello World (with `FOO => BAR` header) +# ``` +# +# #### Dependency Injection +# +# Athena utilizes `Athena::DependencyInjection` to provide a service container layer. +# DI allows controllers/other services to be decoupled from specific implementations. +# This makes testing easier as test implementations of the dependencies can be used. +# +# In Athena, most everything is a service that belongs to the container, which is unique to the current request. The major benefit of this is it allows various types to be shared amongst the services. +# For example, allowing param converters, controllers, etc. to have access to the current request via the `ART::RequestStore` service. +# +# Another example would be defining a service to store a `UUID` to represent the current request, then using this service to include the UUID in the response headers. +# +# ``` +# require "athena" +# require "uuid" +# +# @[ADI::Register] +# struct RequestIDStore +# HEADER_NAME = "X-Request-ID" +# +# # Inject `ART::RequestStore` in order to have access to the current request's headers. +# def initialize(@request_store : ART::RequestStore); end +# +# property request_id : String? = nil do +# # Check the request store for a request. +# request = @request_store.request? +# +# # If there is a request and it has the Header, +# if request && request.headers.has_key? HEADER_NAME +# # use that ID. +# request.headers[HEADER_NAME] +# else +# # otherwise generate a new one. +# UUID.random.to_s +# end +# end +# end +# +# @[ADI::Register] +# struct RequestIDListener +# include AED::EventListenerInterface +# +# def self.subscribed_events : AED::SubscribedEvents +# AED::SubscribedEvents{ +# ART::Events::Response => 0, +# } +# end +# +# def initialize(@request_id_store : RequestIDStore); end +# +# def call(event : ART::Events::Response, dispatcher : AED::EventDispatcherInterface) : Nil +# # Set the request ID as a response header +# event.response.headers[RequestIDStore::HEADER_NAME] = @request_id_store.request_id +# end +# end +# +# class ExampleController < ART::Controller +# get "/" do +# "" +# end +# end +# +# ART.run +# +# # GET / # => (`X-Request-ID => 07bda224-fb1d-4b82-b26c-19d46305c7bc` header) +# ``` +# +# The main benefit of having `RequestIDStore` and not doing `event.response.headers[RequestIDStore::HEADER_NAME] = UUID.random.to_s` directly is that the value could be used in other places. +# Say for example you have a route that enqueues messages to be processed asynchronously. The `RequestIDStore` could be inject into that controller/service in order to include the same `UUID` +# within the message in order to expand tracing to async contexts. Without DI, like in other frameworks, there would not be an easy to way to share the same instance of an object between +# different types. It also wouldn't be easy to have access to data outside the request context. +# +# DI is also what "wires" everything together. For example, say there is an external shard that defines a listener. All that would be required to use that listener is install and require the shard, +# DI takes care of the rest. This is much easier and more flexible than needing to update code to add a new `HTTP::Handler` instance to an array. module Athena::Routing protected class_getter route_resolver : ART::RouteResolver { ART::RouteResolver.new } # The `AED::Event` that are emitted via `Athena::EventDispatcher` to handle a request during its life-cycle. - # Athena adds a `HTTP::Request#attributes` getter that returns a `Hash(String, Bool | Int32 | String | Float64 | Nil)` which can be used to store simple information that can be used later. + # Custom events can also be defined and dispatched within a controller, listener, or some other service. # # See each specific event for more detailed information. module Athena::Routing::Events; end # Exception handling in Athena is similar to exception handling in any Crystal program, with the addition of a new unique exception type, `ART::Exceptions::HTTPException`. # - # When an exception is raised, Athena emits the `ART::Events::Exception` event to allow an opportunity for it to be handled. If the exception goes unhanded, i.e. no listener set + # When an exception is raised, Athena emits the `ART::Events::Exception` event to allow an opportunity for it to be handled. If the exception goes unhandled, i.e. no listener set # an `ART::Response` on the event, then the request is finished and the exception is reraised. Otherwise, that response is returned, setting the status and merging the headers on the exceptions # if it is an `ART::Exceptions::HTTPException`. See `ART::Listeners::Error` and `ART::ErrorRendererInterface` for more information on how exceptions are handled by default. # # To provide the best response to the client, non `ART::Exceptions::HTTPException` should be rescued and converted into a corresponding `ART::Exceptions::HTTPException`. - # Custom HTTP errors can also be defined by inheriting from `ART::Exceptions::HTTPException`. A use case for this could be allowing for additional data/context to be included + # Custom HTTP errors can also be defined by inheriting from `ART::Exceptions::HTTPException` or a child type. A use case for this could be allowing for additional data/context to be included # within the exception that ultimately could be used in a `ART::Events::Exception` listener. module Athena::Routing::Exceptions; end @@ -115,7 +397,7 @@ module Athena::Routing # An `Array(ART::Arguments::ArgumentMetadata)` that `self` requires. getter arguments : ArgumentsType - # An `Array(ART::ParamConverterInterface::ConfigurationInterface)` representing the `ART::ParamConverter`s applied to `self. + # An `Array(ART::ParamConverterInterface::ConfigurationInterface)` representing the `ART::ParamConverter`s applied to `self`. getter param_converters : Array(ART::ParamConverterInterface::ConfigurationInterface) def initialize( @@ -141,7 +423,7 @@ module Athena::Routing Controller end - # Executes `#action` with the provided *arguments* array. + # Executes the action related to `self` with the provided *arguments* array. def execute(arguments : Array) : ReturnType @action.call.call *{{ArgTypeTuple.type_vars.empty? ? "Tuple.new".id : ArgTypeTuple}}.from arguments end @@ -150,6 +432,8 @@ module Athena::Routing # Runs an `HTTP::Server` listening on the given *port* and *host*. # # ``` + # require "athena" + # # class ExampleController < ART::Controller # @[ART::Get("/")] # def root : String @@ -160,15 +444,15 @@ module Athena::Routing # ART.run # ``` # See `ART::Controller` for more information on defining controllers/route actions. - def self.run(port : Int32 = 3000, host : String = "0.0.0.0", ssl : OpenSSL::SSL::Context::Server | Bool | Nil = nil, reuse_port : Bool = false) - ART::Server.new(port, host, ssl, reuse_port).start + def self.run(port : Int32 = 3000, host : String = "0.0.0.0", reuse_port : Bool = false) : Nil + ART::Server.new(port, host, reuse_port).start end # :nodoc: # # Currently an implementation detail. In the future could be exposed to allow having separate "groups" of controllers that a `Server` instance handles. struct Server - def initialize(@port : Int32 = 3000, @host : String = "0.0.0.0", @ssl : OpenSSL::SSL::Context::Server | Bool | Nil = nil, @reuse_port : Bool = false) + def initialize(@port : Int32 = 3000, @host : String = "0.0.0.0", @reuse_port : Bool = false) # Define the server @server = HTTP::Server.new do |context| # Handle the request diff --git a/src/controller.cr b/src/controller.cr index 5e14ddbd..80417041 100644 --- a/src/controller.cr +++ b/src/controller.cr @@ -3,7 +3,7 @@ # # Additional annotations also exist for setting a query param or a param converter. See `ART::QueryParam` and `ART::ParamConverter` respectively. # -# Child controllers must inherit from `ART::Controller` (or an abstract child of it). Each request gets its own instance of the controller to better allow for DI via `Athena::DI`. +# Child controllers must inherit from `ART::Controller` (or an abstract child of it). Each request gets its own instance of the controller to better allow for DI via `Athena::DependencyInjection`. # # A route action can either return an `ART::Response`, or some other type. If an `ART::Response` is returned, then it is used directly. Otherwise an `ART::Events::View` is emitted to convert # the action result into an `ART::Response`. By default, `ART::Listeners::View` will JSON encode the value if it is not handled earlier by another listener. @@ -20,9 +20,9 @@ # @[ART::Prefix("athena")] # class TestController < ART::Controller # # A GET endpoint returning an `ART::Response`. -# @[ART::Get(path: "/css")] +# @[ART::Get("/css")] # def css : ART::Response -# ART::Response.new ".some_class { color: blue; }", 200, HTTP::Headers{"content-type" => MIME.from_extension(".css")} +# ART::Response.new ".some_class { color: blue; }", headers: HTTP::Headers{"content-type" => MIME.from_extension(".css")} # end # # # A GET endpoint using a param converter to render a template. @@ -32,8 +32,8 @@ # # # user.ecr # # Morning, <%= user.name %> it is currently <%= time %>. # # ``` -# @[ART::ParamConverter(param: "user", converter: SomeConverter(User))] -# @[ART::Get(path: "/wakeup/:id")] +# @[ART::ParamConverter("user", converter: SomeConverter)] +# @[ART::Get("/wakeup/:id")] # def wakeup(user : User) : ART::Response # # Template variables not supplied in the action's arguments must be defined manually # time = Time.utc @@ -45,7 +45,7 @@ # # A GET endpoint with no params returning a `String`. # # # # Action return type restrictions are required. -# @[ART::Get(path: "/me")] +# @[ART::Get("/me")] # def get_me : String # "Jim" # end @@ -53,7 +53,7 @@ # # A GET endpoint with no params returning `Nil`. # # `Nil` return types are returned with a status # # of 204 no content -# @[ART::Get(path: "/no_content")] +# @[ART::Get("/no_content")] # def get_no_content : Nil # # Do stuff # end @@ -62,7 +62,7 @@ # # # # The parameters of a route _MUST_ match the arguments of the action. # # Type restrictions on action arguments are required. -# @[ART::Get(path: "/add/:val1/:val2")] +# @[ART::Get("/add/:val1/:val2")] # def add(val1 : Int32, val2 : Int32) : Int32 # val1 + val2 # end @@ -71,7 +71,7 @@ # # # # A non-nilable type denotes it as required. If the parameter is not supplied, and no default value is assigned, an `ART::Exceptions::BadRequest` exception is raised. # @[ART::QueryParam("time", constraints: /\d:\d:\d/)] -# @[ART::Get(path: "/event/:event_name/")] +# @[ART::Get("/event/:event_name/")] # def event_time(event_name : String, time : String) : String # "#{event_name} occurred at #{time}" # end @@ -80,13 +80,13 @@ # # # # A nilable type denotes it as optional. If the parameter is not supplied (or could not be converted), and no default value is assigned, it is `nil`. # @[ART::QueryParam("user_id")] -# @[ART::Get(path: "/events/(:page)")] +# @[ART::Get("/events/(:page)")] # def events(user_id : Int32?, page : Int32 = 1) : NamedTuple(user_id: Int32?, page: Int32) # {user_id: user_id, page: page} # end # # # A GET endpoint with param constraints. The param must match the supplied Regex or it will not match and return a 404 error. -# @[ART::Get(path: "/time/:time/", constraints: {"time" => /\d{2}:\d{2}:\d{2}/})] +# @[ART::Get("/time/:time/", constraints: {"time" => /\d{2}:\d{2}:\d{2}/})] # def get_constraint(time : String) : String # time # end @@ -95,27 +95,25 @@ # # # # It is recommended to use param converters to pass an actual object representing the data (assuming the body is JSON) # # to the route's action; however the raw request body can be accessed by typing an action argument as `HTTP::Request`. -# @[ART::Post(path: "/test/:expected")] +# @[ART::Post("/test/:expected")] # def post_body(expected : String, request : HTTP::Request) : Bool # expected == request.body.try &.gets_to_end # end # end # -# spawn ART.run -# -# CLIENT = HTTP::Client.new "localhost", 3000 -# -# CLIENT.get("/athena/css").body # => .some_class { color: blue; } -# CLIENT.get("/athena/wakeup/17").body # => Morning, Allison it is currently 2020-02-01 18:38:12 UTC. -# CLIENT.get("/athena/me").body # => "Jim" -# CLIENT.get("/athena/add/50/25").body # => 75 -# CLIENT.get("/athena/event/foobar?time=1:1:1").body # => "foobar occurred at 1:1:1" -# CLIENT.get("/athena/events").body # => {"user_id":null,"page":1} -# CLIENT.get("/athena/events/17?user_id=19").body # => {"user_id":19,"page":17} -# CLIENT.get("/athena/time/12:45:30").body # => "12:45:30" -# CLIENT.get("/athena/time/12:aa:30").body # => 404 not found -# CLIENT.get("/athena/no_content").body # => 204 no content -# CLIENT.post("/athena/test/foo", body: "foo").body # => true +# ART.run +# +# # GET /athena/css" # => .some_class { color: blue; } +# # GET /athena/wakeup/17" # => Morning, Allison it is currently 2020-02-01 18:38:12 UTC. +# # GET /athena/me" # => "Jim" +# # GET /athena/add/50/25" # => 75 +# # GET /athena/event/foobar?time=1:1:1" # => "foobar occurred at 1:1:1" +# # GET /athena/events" # => {"user_id":null,"page":1} +# # GET /athena/events/17?user_id=19" # => {"user_id":19,"page":17} +# # GET /athena/time/12:45:30" # => "12:45:30" +# # GET /athena/time/12:aa:30" # => 404 not found +# # GET /athena/no_content" # => 204 no content +# # POST /athena/test/foo", body: "foo" # => true # ``` abstract class Athena::Routing::Controller # Renders a template. @@ -138,10 +136,9 @@ abstract class Athena::Routing::Controller # end # end # - # spawn ART.run + # ART.run # - # CLIENT = HTTP::Client.new "localhost", 3000 - # CLIENT.get("/Fred").body # => Greetings, Fred! + # # GET /Fred # => Greetings, Fred! # ``` macro render(template) Athena::Routing::Response.new(headers: HTTP::Headers{"content-type" => "text/html"}) do |io| @@ -165,10 +162,9 @@ abstract class Athena::Routing::Controller # end # end # - # spawn ART.run + # ART.run # - # CLIENT = HTTP::Client.new "localhost", 3000 - # CLIENT.get("/Fred").body # =>

Content:

Greetings, Fred! + # # GET /Fred # =>

Content:

Greetings, Fred! # ``` macro render(template, layout) content = ECR.render {{template}} diff --git a/src/error_renderer.cr b/src/error_renderer.cr index b2a82e4b..a717a664 100644 --- a/src/error_renderer.cr +++ b/src/error_renderer.cr @@ -1,4 +1,4 @@ -@[ADI::Register(name: "error_renderer", alias: Athena::Routing::ErrorRendererInterface)] +@[ADI::Register(alias: Athena::Routing::ErrorRendererInterface)] # The default `ART::ErrorRendererInterface`, JSON serializes the exception. struct Athena::Routing::ErrorRenderer include Athena::Routing::ErrorRendererInterface diff --git a/src/error_renderer_interface.cr b/src/error_renderer_interface.cr index a4594dc4..7eacfd54 100644 --- a/src/error_renderer_interface.cr +++ b/src/error_renderer_interface.cr @@ -4,11 +4,12 @@ # to allow rendering errors differently, such as via HTML. # # ``` -# @[ADI::Register(name: "error_renderer")] -# # A custom error renderer must redefine the default `ART::ErrorRenderer` by registering a service with the name `"error_renderer"`. +# require "athena" +# +# # Alias this service to be used when the `ART::ErrorRendererInterface` type is encountered. +# @[ADI::Register(alias: ART::ErrorRendererInterface)] # struct Athena::Routing::CustomErrorRenderer # include Athena::Routing::ErrorRendererInterface -# include ADI::Service # # # :inherit: # def render(exception : ::Exception) : ART::Response @@ -36,6 +37,16 @@ # ART::Response.new body, status, headers # end # end +# +# class TestController < ART::Controller +# get "/" do +# raise "some error" +# end +# end +# +# ART.run +# +# # GET / # => Uh oh

Uh oh, something went wrong

# ``` module Athena::Routing::ErrorRendererInterface # Renders the given *exception* into an `ART::Response`. diff --git a/src/ext/conversion_types.cr b/src/ext/conversion_types.cr index 6b596cf9..262b3dbd 100644 --- a/src/ext/conversion_types.cr +++ b/src/ext/conversion_types.cr @@ -1,7 +1,9 @@ +# :nodoc: def Object.from_parameter(value) value end +# :nodoc: def Bool.from_parameter(value : String) : Bool if value == "true" true @@ -12,6 +14,7 @@ def Bool.from_parameter(value : String) : Bool end end +# :nodoc: def Union.from_parameter(value : String) # Process non nilable types first as they are more likely to work. {% for type in T.sort_by { |t| t.nilable? ? 1 : 0 } %} @@ -19,6 +22,7 @@ def Union.from_parameter(value : String) {% end %} end +# :nodoc: def Number.from_parameter(value : String) : Number new value end diff --git a/src/param_converter_interface.cr b/src/param_converter_interface.cr index fc5faea3..abe926ba 100644 --- a/src/param_converter_interface.cr +++ b/src/param_converter_interface.cr @@ -17,7 +17,7 @@ # require "athena" # # # Create a param converter struct to contain our conversion logic. -# @[ADI::Register(tags: [ART::ParamConverterInterface::TAG])] +# @[ADI::Register] # struct MultiplyConverter < ART::ParamConverterInterface # # :inherit: # def apply(request : HTTP::Request, configuration : Configuration) : Nil @@ -60,7 +60,7 @@ # ``` # require "athena" # -# @[ADI::Register(tags: [ART::ParamConverterInterface::TAG])] +# @[ADI::Register] # struct MultiplyConverter < ART::ParamConverterInterface # # Use the `configuration` macro to define the configuration object that `self` should use. # # Adds an additional argument to allow specifying the multiplier. diff --git a/src/parameter_bag.cr b/src/parameter_bag.cr index 5886ff1d..82a601c2 100644 --- a/src/parameter_bag.cr +++ b/src/parameter_bag.cr @@ -1,4 +1,43 @@ # A container for storing key/value pairs. Can be used to store arbitrary data within the context of a request. +# It can be accessed via `HTTP::Request#attributes`. +# +# ### Example +# +# For example, an artbirary value can be stored in the attributes, and later provided as an action argument. +# +# ``` +# require "athena" +# +# # Define a request listener to add our value before the action is executed. +# @[ADI::Register] +# struct TestListener +# include AED::EventListenerInterface +# +# def self.subscribed_events : AED::SubscribedEvents +# AED::SubscribedEvents{ +# ART::Events::Request => 0, +# } +# end +# +# def call(event : ART::Events::Request, dispatcher : AED::EventDispatcherInterface) : Nil +# # Store our value within the request's attributes, restricted to a `String`. +# event.request.attributes.set "my_arg", "foo", String +# end +# end +# +# class ExampleController < ART::Controller +# # Define an action argument with the same name of the argument stored in attributes. +# # +# # The argument is resolved via `ART::Arguments::Resolvers::RequestAttribute`. +# get "/", my_arg : String do +# my_arg +# end +# end +# +# ART.run +# +# # GET / # => "foo" +# ``` struct Athena::Routing::ParameterBag private abstract struct Param abstract def value @@ -22,7 +61,7 @@ struct Athena::Routing::ParameterBag # # Raises a `KeyError` if no parameter with that name exists. def get(name : String) - get?(name) || raise KeyError.new "No parameter exists with the name '#{name}'." + self.get?(name) || raise KeyError.new "No parameter exists with the name '#{name}'." end {% for type in [Bool, String] + Number::Primitive.union_types %} @@ -33,8 +72,8 @@ struct Athena::Routing::ParameterBag {% end %} # Sets a parameter with the provided *name* to *value*. - def set(name : String, value : _) : Nil - @parameters[name] = Parameter.new value + def set(name : String, value : T) : Nil forall T + self.set name, value, T end # Sets a parameter with the provided *name* to *value*, restricted to the given *type*. diff --git a/src/redirect_response.cr b/src/redirect_response.cr index c9b01521..8d453160 100644 --- a/src/redirect_response.cr +++ b/src/redirect_response.cr @@ -2,15 +2,21 @@ require "./response" # Represents an HTTP response that does a redirect. # -# Can be used as an easier way to handle redirects as well as providing type saftey that a route should redirect. +# Can be used as an easier way to handle redirects as well as providing type safety that a route should redirect. # # ``` +# require "athena" +# # class RedirectController < ART::Controller # @[ART::Get(path: "/go_to_crystal")] # def redirect_to_crystal : ART::RedirectResponse # ART::RedirectResponse.new "https://crystal-lang.org" # end # end +# +# ART.run +# +# # GET /go_to_crystal # => (redirected to https://crystal-lang.org) # ``` class Athena::Routing::RedirectResponse < Athena::Routing::Response # The url that the request will be redirected to. @@ -26,6 +32,6 @@ class Athena::Routing::RedirectResponse < Athena::Routing::Response super "", status, headers - raise ArgumentError.new "#{@status.value} is not an HTTP redirect status code." unless @status.redirection? + raise ArgumentError.new "'#{@status.value}' is not an HTTP redirect status code." unless @status.redirection? end end diff --git a/src/request_store.cr b/src/request_store.cr index 9c465c5c..1389ca9d 100644 --- a/src/request_store.cr +++ b/src/request_store.cr @@ -2,28 +2,30 @@ # Stores the current `HTTP::Request` object. # # Can be injected to access the request from a non controller context. +# +# ``` +# require "athena" +# +# @[ADI::Register(public: true)] +# class ExampleController < ART::Controller +# def initialize(@request_store : ART::RequestStore); end +# +# get "/" do +# @request_store.method +# end +# end +# +# ART.run +# +# # GET / # => GET +# ``` class Athena::Routing::RequestStore - @request : HTTP::Request? = nil + property! request : HTTP::Request # Resets the store, removing the reference to the request. # - # Used internally after the request has been returned. - def reset : Nil + # Used internally after the response has been returned. + protected def reset : Nil @request = nil end - - # Returns the currently executing request. - # - # Use `#request?` if it's possible there is no request. - def request : HTTP::Request - @request.not_nil! - end - - # Returns the currently executing request if it exists, otherwise `nil`. - def request? : HTTP::Request? - @request - end - - # Sets the currently executing request. - def request=(@request : HTTP::Request); end end diff --git a/src/response.cr b/src/response.cr index fc63dd11..ad92a0d4 100644 --- a/src/response.cr +++ b/src/response.cr @@ -16,19 +16,20 @@ class Athena::Routing::Response # ### Example # # ``` - # require "gzip" + # require "athena" + # require "compress/gzip" # # # Define a custom writer to gzip the response # struct GzipWriter < ART::Response::Writer # def write(output : IO, & : IO -> Nil) : Nil - # Gzip::Writer.open(output) do |gzip_io| + # Compress::Gzip::Writer.open(output) do |gzip_io| # yield gzip_io # end # end # end # + # # Define a new event listener to handle applying this writer # @[ADI::Register] - # # Next define a new event listener to handle applying this writer # struct CompressionListener # include AED::EventListenerInterface # @@ -49,6 +50,17 @@ class Athena::Routing::Response # end # end # end + # + # class ExampleController < ART::Controller + # @[ART::Get("/users")] + # def users : Array(User) + # User.all + # end + # end + # + # ART.run + # + # # GET /users # => [{"id":1,...},...] (gzipped) # ``` abstract struct Writer # Accepts an *output* `IO` that the content of the response should be written to. @@ -86,7 +98,25 @@ class Athena::Routing::Response # Creates a new response with optional *status*, and *headers* arguments. # - # The block is captured and called when `self` is being written to the response IO. + # The block is captured and called when `self` is being written to the response `IO`. + # This can be useful to reduce memory overhead when needing to return large responses. + # + # ``` + # require "athena" + # + # class ExampleController < ART::Controller + # @[ART::Get("/users")] + # def users : ART::Response + # ART::Response.new headers: HTTP::Headers{"content-type" => "application/json"} do |io| + # User.all.to_json io + # end + # end + # end + # + # ART.run + # + # # GET /users # => [{"id":1,...},...] + # ``` def self.new(status : HTTP::Status | Int32 = HTTP::Status::OK, headers : HTTP::Headers = HTTP::Headers.new, &block : IO -> Nil) new block, status, headers end