diff --git a/docs/getting_started/configuration.md b/docs/getting_started/configuration.md index 4c974cd55..5612fdc6a 100644 --- a/docs/getting_started/configuration.md +++ b/docs/getting_started/configuration.md @@ -1,41 +1,181 @@ Some features need to be configured; either to enable/control how they work, or to customize the default functionality. - +The [ATH.configure](/Framework/top_level/#Athena::Framework:configure(config)) macro is the primary entrypoint for configuring Athena Framework applications. +It is used in conjunction with the related [bundle schema](/Framework/Bundle/Schema/Cors/Defaults/) that defines the possible configuration properties: -Configuration in Athena is mainly focused on controlling _how_ specific features/components provided by Athena itself, or third parties, function at runtime. -A more concrete example would be how [ATH::Config::CORS](/Framework/Config/CORS) can be used to control [ATH::Listeners::CORS](/Framework/Listeners/CORS). -Say we want to enable CORS for our application from our app URL, expose some custom headers, and allow credentials to be sent. -To do this we would want to redefine the configuration type's `self.configure` method. -This method should return an instance of `self`, configured how we wish. Alternatively, it could return `nil` to disable the listener, which is the default. +```crystal +ATH.configure({ + framework: { + cors: { + enabled: true, + defaults: { + allow_credentials: true, + allow_origin: ["https://app.example.com"], + expose_headers: ["X-Transaction-ID X-Some-Custom-Header"], + }, + }, + }, +}) +``` + +In this example we enable the [CORS Listener](/Framework/Listeners/CORS), as well as configure it to function as we desire. +However you may be wondering "how do I know what configuration properties are available?" or "what is that 'bundle schema' thing mentioned earlier?". +For that we need to introduce the concept of a `Bundle`. + +## Bundles + +It should be well known by now that the components that make up Athena's ecosystem are independent and usable outside of the Athena Framework itself. +However because they are made with the assumption that the entire framework will not be available, there has to be something that provides the tighter integration into the rest of the framework that makes it all work together so nicely. + +Bundles in the Athena Framework provide the mechanism by which external code can be integrated into the rest of the framework. +This primarily involves wiring everything up via the [Athena::DependencyInjection](/DependencyInjection) component. +But it also ties back into the configuration theme by allowing the user to control _how_ things are wired up and/or function at runtime. + +What makes the bundle concept so powerful and flexible is that it operates at the compile time level. +E.g. if feature(s) are disabled in the configuration, then the types related to those feature(s) will not be included in the resulting binary at all. +Similarly, the configuration values can be accessed/used as constructor arguments to the various services, something a runtime approach would not allow. + +TODO: Expand upon bundle internals and how to create custom bundles. + +### Schemas + +Each bundle is responsible for defining a "schema" that represents the possible configuration properties that relate to the services provided by that bundle. +Each bundle also has a name that is used to namespace the configuration passed to `ATH.configure`. +From there, the keys maps to the downcase snakecased of the types found within the bundle's schema. +For example, the [Framework Bundle](/Framework/Bundle) used in the previous example, exposes `cors` and `format_listener` among others as part of its schema. + +NOTE: Bundles and schemas are not something the average end user is going to need to define/manage themselves other than register/configure to fit their needs. + +#### Validation + +The compile time nature of bundles also extends to how their schemas are validated. +Bundles will raise a compile time error if the provided configuration values are invalid according to its schema. +For example: ```crystal -def ATH::Config::CORS.configure - new( - allow_credentials: true, - allow_origin: %(https://app.example.com), - expose_headers: %w(X-Transaction-ID X-Some-Custom-Header), - ) -end + 10 | allow_credentials: 10, + ^ +Error: Expected configuration value 'framework.cors.defaults.allow_credentials' to be a 'Bool', but got 'Int32'. +``` + +This also works for nested values: + +```crystal + 10 | allow_origin: [10, "https://app.example.com"] of String, + ^ +Error: Expected configuration value 'framework.cors.defaults.allow_origin[0]' to be a 'String', but got 'Int32'. +``` + +Or if the schema defines a value that is not nilable nor has a default: + +```crystal + 10 | property some_property : String + ^------------ +Error: Required configuration property 'framework.some_property : String' must be provided. +``` + +It can also call out unexpected keys: + +```crystal + 10 | foo: "bar", + ^ +Error: Encountered unexpected property 'framework.cors.foo' with value '"bar"'. +``` + +Hash configuration values are unchecked so are best used for unstructured data. +If you have a fixed set of related configuration, consider using [object_of](/DependencyInjection/Extension/Schema/#Athena::DependencyInjection::Extension::Schema:object_of(name,*)). + +#### Multi-Environment + +In most cases, the configuration for each bundle is likely going to vary one environment to another. +Values that change machine to machine should ideally be leveraging environmental variables. +However, there are also cases where the underlying configuration should be different. +E.g. locally use an in-memory cache while using redis in other environments. + +To handle this, `ATH.configure` may be called multiple times, with the last call taking priority. +The configuration is deep merged together as well, so only the configuration you wish to alter needs to be defined. +However hash/array/namedTuple values are not. +Normal compile time logic may be used to make these conditional as well. +E.g. basing things off `--release` or `--debug` flags vs the environment. + +```crystal +ATH.configure({ + framework: { + cors: { + defaults: { + allow_credentials: true, + allow_origin: ["https://app.example.com"], + expose_headers: ["X-Transaction-ID", "X-Debug-Header"], + }, + }, + }, +}) + +# Exclude the debug header in prod, but retain the other two configuration property values +{% if env(Athena::ENV_NAME) == "prod" %} +ATH.configure({ + framework: { + cors: { + defaults: { + expose_headers: ["X-Transaction-ID"], + }, + }, + }, +}) +{% end %} + +# Do this other thing if in a non-release build +{% unless flag? "release" %} +ATH.configure({...}) +{% end %} ``` -Configuration objects may also be injected as you would any other service. This can be especially helpful for Athena extensions created by third parties whom services should be configurable by the end use. +TIP: Consider abstracting the additional `ATH.configure` calls to their own files, and `require` them. +This way things stay pretty organized, without needing large conditional logic blocks. ## Parameters -Parameters represent reusable configuration values that can be used when configuring the framework, or directly within the application's services. -For example, the URL of the application is a common piece of information, used both in configuration and other services for redirects. -This URl could be defined as a parameter to allow its definition to be centralized and reused. +Sometimes the same configuration value is used in several places within `ATH.configure`. +Instead of repeating it, you can define it as a "parameter", which represents reusable configuration values. +Parameters are intended for values that do not change between machines, and control the application's behavior, e.g. the sender of notification emails, what features are enabled, or other high level application level values. Parameters should _NOT_ be used for values that rarely change, such as the max amount of items to return per page. These types of values are better suited to being a [constant](https://crystal-lang.org/reference/syntax_and_semantics/constants.html) within the related type. Similarly, infrastructure related values that change from one machine to another, e.g. development machine to production server, should be defined using environmental variables. -Parameters are intended for values that do not change between machines, and control the application's behavior, e.g. the sender of notification emails, what features are enabled, or other high level application level values. -To reiterate, the primary benefit of parameters is to centralize and decouple their values from the types that actually use them. -Another benefit is they offer full compile time safety, if for example, the type of `app_url` was mistakenly set to `Int32` or if the parameter's name was typo'd, e.g. `"%app.ap_url%"`; both would result in compile time errors. +Parameters can be defined using the special top level `parameters` key within `ATH.configure`. + +```crystal +ATH.configure({ + parameters: { + # The parameter name is an arbitrary string, + # but is suggested to use some sort of prefix to differentiate your parameters + # from the built-in framework parameters, as well as other bundles. + "app.admin_email": "admin@example.com", + + # Boolean param + "app.enable_v2_protocol": true, + + # Collection param + "app.supported_locales": ["en", "es", "de"], + }, +}) +``` + +The parameter value may be any primitive type, including strings, bools, hashes, arrays, etc. +From here they can be used when configuring a bundle via enclosing the name of the parameter within `%`. +For example: + +```crystal +ATH.configure({ + some_bundle: { + email: "%app.admin_email%", + }, +}) +``` - +TIP: Parameters may also be [injected](/DependencyInjection/Register/#Athena::DependencyInjection::Register--parameters) directly into services via their constructor. ## Custom Annotations diff --git a/docs/getting_started/routing.md b/docs/getting_started/routing.md index 326238f6e..e921eae6a 100644 --- a/docs/getting_started/routing.md +++ b/docs/getting_started/routing.md @@ -257,6 +257,11 @@ end ## URL Generation A common use case, especially when rendering `HTML`, is generating links to other routes based on a set of provided parameters. +When in the context of a request, the scheme and hostname of a [ART::Generator::ReferenceType::ABSOLUTE_URL](/Routing/Generator/ReferenceType/#Athena::Routing::Generator::ReferenceType::ABSOLUTE_URL) defaults to `http` and `localhost` respectively, if they could not be extracted from the request. + +### In Controllers + +The parent [ATH::Controller](/Framework/Controller) type provides some helper methods for generating URLs within the context of a controller. ```crystal require "athena" @@ -291,9 +296,48 @@ ATH.run # GET / # => 10 ``` -When a route is generated in the context of a request, the scheme and hostname of a [ART::Generator::ReferenceType::ABSOLUTE_URL](/Routing/Generator/ReferenceType/#Athena::Routing::Generator::ReferenceType::ABSOLUTE_URL) defaults to `http` and `localhost` respectively, if they could not be extracted from the request. -However, in cases where there is no request to use, such as within an [ACON::Command](/Console/Command), `http://localhost/` would always be the scheme and hostname of the generated URL. -[ATH::Parameters.configure](/Framework/Parameters/#Athena::Framework::Parameters.configure) can be used to customize this, as well as define a global path prefix when generating the URLs. +NOTE: Passing arguments to `#generate_url` that are not part of the route definition are included within the query string of the generated URL. +```crystal +self.generate_url "blog", page: 2, category: "Crystal" +# The "blog" route only defines the "page" parameter; the generated URL is: +# /blog/2?category=Crystal +``` + +### In Services + +A service can define a constructor parameter typed as [ART::Generator::Interface](/Routing/Generator/Interface) in order to obtain the `router` service: + +```crystal +@[ADI::Register] +class SomeService + def initialize(@url_generator : ART::Generator::Interface); end + + def some_method : Nil + sign_up_page = @url_generator.generate "sign_up" + + # ... + end +end +``` + +### In Commands + +Generating URLs in [commands](./commands.md) works the same as in a service. +However, commands are not executed in an HTTP context. +Because of this, absolute URLs will always generate as `http://localhost/` instead of your actual host name. + +The solution to this is to configure the [framework.router.default_uri](/Framework/Bundle/Schema/Router/#Athena::Framework::Bundle::Schema::Router#default_uri) configuration value. +This'll ensure URLs generated within commands have the proper host. + +```crystal +ATH.configure({ + framework: { + router: { + default_uri: "https://example.com/my/path", + }, + }, +}) +``` ## WebSockets @@ -320,31 +364,39 @@ Alternatively, the [Athena::Mercure](/Mercure) component may be used as a replac As mentioned earlier, controller action responses are JSON serialized if the controller action does _NOT_ return an [ATH::Response](/Framework/Response). The [Negotiation](/Negotiation) component enhances the view layer of the Athena Framework by enabling [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3) support; making it possible to write format agnostic controllers by placing a layer of abstraction between the controller and generation of the final response content. -Or in other words allow having the same controller action be rendered based on the request's [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) `HTTP` header and the format priority configuration. +Or in other words, allow having the same controller action be rendered based on the request's [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) header and the format priority configuration. ### Format Priority -The content negotiation logic is disabled by default, but can be easily enabled by redefining [ATH::Config::ContentNegotiation.configure](/Framework/Config/ContentNegotiation/#Athena::Framework::Config::ContentNegotiation.configure) with the desired configuration. -Content negotiation configuration is represented by an array of [Rule](/Framework/Config/ContentNegotiation/Rule/) used to describe allowed formats, their priorities, and how things should function if a unsupported format is requested. +The content negotiation logic is disabled by default, but can be easily enabled via the related [bundle configuration](./configuration.md). +Content negotiation configuration is represented by an array of [rules](/Framework/Bundle/Schema/FormatListener/#Athena::Framework::Bundle::Schema::FormatListener#rules) used to describe allowed formats, their priorities, and how things should function if a unsupported format is requested. For example, say we configured things like: ```crystal -def ATH::Config::ContentNegotiation.configure - new( - # Setting fallback_format to json means that instead of considering - # the next rule in case of a priority mismatch, json will be used. - Rule.new(priorities: ["json", "xml"], host: "api.example.com", fallback_format: "json"), - # Setting fallback_format to false means that instead of considering - # the next rule in case of a priority mismatch, a 406 will be returned. - Rule.new(path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false), - # Setting fallback_format to nil (or not including it) means that - # in case of a priority mismatch the next rule will be considered. - Rule.new(path: /^\/admin/, priorities: ["xml", "html"]), - # Setting a priority to */* basically means any format will be matched. - Rule.new(priorities: ["text/html", "*/*"], fallback_format: "html"), - ) -end +ATH.configure({ + framework: { + format_listener: { + enabled: true, + rules: [ + # Setting fallback_format to json means that instead of considering + # the next rule in case of a priority mismatch, json will be used. + {priorities: ["json", "xml"], host: /api\.example\.com/, fallback_format: "json"}, + + # Setting fallback_format to false means that instead of considering + # the next rule in case of a priority mismatch, a 406 will be returned. + {path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false}, + + # Setting fallback_format to nil (or not including it) means that + # in case of a priority mismatch the next rule will be considered. + {path: /^\/admin/, priorities: ["xml", "html"]}, + + # Setting a priority to */* basically means any format will be matched. + {priorities: ["text/html", "*/*"], fallback_format: "html"}, + ], + }, + }, +}) ``` Assuming an `accept` header with the value `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json`: a request made to `/foo` from the `api.example.com` hostname; the request format would be `json`. If the request was not made from that hostname; the request format would be `html`. The rules can be as complex or as simple as needed depending on the use case of your application. @@ -352,18 +404,20 @@ Assuming an `accept` header with the value `text/html,application/xhtml+xml,appl ### View Handler The [ATH::View::ViewHandler](/Framework/View/ViewHandler) is responsible for generating an [ATH::Response](/Framework/Response) in the format determined by the [ATH::Listeners::Format](/Framework/Listeners/Format), otherwise falling back on the request's [format](/Framework/Request/#Athena::Framework::Request#format(mime_type)), defaulting to `json`. -The view handler has a few configurable options that can be customized if so desired. -This can be achieved via redefining [Athena::Framework::Config::ViewHandler.configure](/Framework/Config/ViewHandler/#Athena::Framework::Config::ViewHandler.configure). +The view handler has a options that may also be [configured](./configuration.md) via the [ATH::Bundle::Schema::ViewHandler](/Framework/Bundle/Schema/ViewHandler) schema. ```crystal -def ATH::Config::ViewHandler.configure : ATH::Config::ViewHandler - new( - # The HTTP::Status to use if there is no response body, defaults to 204. - empty_content_status: :im_a_teapot, - # If `nil` values should be serialized, defaults to false. - emit_nil: true - ) -end +ATH.configure({ + framework: { + view_handler: { + # The HTTP::Status to use if there is no response body, defaults to 204. + empty_content_status: :im_a_teapot, + + # If `nil` values should be serialized, defaults to false. + serialize_nil: true + }, + }, +}) ``` ## Views diff --git a/src/components/dependency_injection/spec/extension_spec.cr b/src/components/dependency_injection/spec/extension_spec.cr index 3eb26e6af..8767a5422 100644 --- a/src/components/dependency_injection/spec/extension_spec.cr +++ b/src/components/dependency_injection/spec/extension_spec.cr @@ -163,7 +163,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rule","type":"`NamedTuple(T)`","default":"``","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rule","type":"`NamedTuple(T)`","default":"``","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end @@ -197,7 +197,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rule","type":"`NamedTuple(T)`","default":"`{id: 999}`","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rule","type":"`NamedTuple(T)`","default":"`{id: 999}`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end @@ -224,7 +224,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rule","type":"`(NamedTuple(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rule","type":"`(NamedTuple(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end @@ -251,7 +251,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rule","type":"`(NamedTuple(T) | Nil)`","default":"`{id: 999}`","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rule","type":"`(NamedTuple(T) | Nil)`","default":"`{id: 999}`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end @@ -303,7 +303,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rules","type":"`Array(T)`","default":"`[{id: 10, stop: true}]`","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rules","type":"`Array(T)`","default":"`[{id: 10, stop: true}]`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end @@ -330,7 +330,7 @@ describe ADI::Extension do {{OPTIONS[0]["members"]["stop"].value.stringify}}.should eq "false" {{CONFIG_DOCS.stringify}}.should eq <<-JSON - [{"name":"rules","type":"`(Array(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":"NONE"},{"name":"stop","type":"`Bool`","default":"`false`","doc":"NONE"}]}] of Nil + [{"name":"rules","type":"`(Array(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil JSON end end diff --git a/src/components/dependency_injection/src/annotations.cr b/src/components/dependency_injection/src/annotations.cr index dcbc1057c..9d6df6316 100644 --- a/src/components/dependency_injection/src/annotations.cr +++ b/src/components/dependency_injection/src/annotations.cr @@ -273,28 +273,16 @@ module Athena::DependencyInjection # # ### Parameters # - # The `Athena::Config` component provides a way to manage `ACF::Parameters` objects used to define reusable [parameters](/getting_started/configuration#parameters). - # It is possible to inject these parameters directly into services in a type safe way. - # - # Parameter injection utilizes a specially formatted string, similar to tagged services. - # The parameter name should be a string starting and ending with a `%`, e.g. `"%app.database.username%"`. - # The value within the `%` represents the "path" to the parameter from the `ACF::Parameters` base type. - # + # Reusable configuration [parameters](/getting_started/configuration#parameters) can be injected directly into services using the same syntax as when used within `ADI.configure`. # Parameters may be supplied either via `Athena::DependencyInjection.bind` or an explicit service argument. # # ``` - # struct DatabaseConfig - # getter username : String = "USERNAME" - # end - # - # struct AppConfig - # getter name : String = "My App" - # getter database : DatabaseConfig = DatabaseConfig.new - # end - # - # class Athena::Config::Parameters - # getter app : AppConfig = AppConfig.new - # end + # ADI.configure({ + # parameters: { + # "app.name": "My App", + # "app.database.username": "administrator", + # }, + # }) # # ADI.bind db_username, "%app.database.username%" # @@ -306,93 +294,6 @@ module Athena::DependencyInjection # service.db_username # => "USERNAME" # ``` # - # ### Configuration - # - # The `Athena::Config` component provides a way to manage `ACF::Base` objects used for [configuration](/getting_started/configuration). - # The `Athena::DependencyInjection` component leverages the `ACFA::Resolvable` annotation to allow injecting entire configuration objects into services - # in addition to individual [parameters][Athena::DependencyInjection::Register--parameters]. - # - # The primary use case for is for types that have functionality that should be configurable by the end user. - # The configuration object could be injected as a constructor argument to set the value of instance variables, or be one itself. - # - # ``` - # # Define an example configuration type for a fictional Athena component. - # # The annotation argument describes the "path" to this particular configuration - # # type from `ACF.config`. I.e. `ACF.config.some_component`. - # @[ACFA::Resolvable("some_component")] - # struct SomeComponentConfig - # # By default return a new instance with a default value. - # def self.configure : self - # new - # end - # - # getter multiplier : Int32 - # - # def initialize(@multiplier : Int32 = 1); end - # end - # - # # This type would be a part of the `ACF::Base` type. - # class ACF::Base - # getter some_component : SomeComponentConfig = SomeComponentConfig.configure - # end - # - # # Define an example configurable service to use our configuration object. - # @[ADI::Register(public: true)] - # class MultiplierService - # @multiplier : Int32 - # - # def initialize(config : SomeComponentConfig) - # @multiplier = config.multiplier - # end - # - # def multiply(value : Number) - # value * @multiplier - # end - # end - # - # ADI.container.multiplier_service.multiply 10 # => 10 - # ``` - # - # By default our `MultiplierService` will use a multiplier of `1`, the default value in the `SomeComponentConfig`. - # However, if we wanted to change that value we could do something like this, without changing any of the earlier code. - # - # ``` - # # Override the configuration type's configure method - # # to supply our own custom multiplier value. - # def SomeComponentConfig.configure - # new 10 - # end - # - # ADI.container.multiplier_service.multiply 10 # => 100 - # ``` - # - # If the configurable service is also used outside of the service container, - # the [factory][Athena::DependencyInjection::Register--factories] pattern could also be used. - # - # ``` - # @[ADI::Register(public: true)] - # class MultiplierService - # # Tell the service container to use this constructor for DI. - # @[ADI::Inject] - # def self.new(config : SomeComponentConfig) - # # Using the configuration object to supply the argument to the standard initialize method. - # new config.multiplier - # end - # - # def initialize(@multiplier : Int32); end - # - # def multiply(value : Number) - # value * @multiplier - # end - # end - # - # # Multiplier from the service container. - # ADI.container.multiplier_service.multiply 10 # => 10 - # - # # A directly instantiated type. - # MultiplierService.new(10).multiply 10 # => 100 - # ``` - # # ### Optional Services # # Services defined with a nillable type restriction are considered to be optional. If no service could be resolved from the type, then `nil` is injected instead. diff --git a/src/components/dependency_injection/src/athena-dependency_injection.cr b/src/components/dependency_injection/src/athena-dependency_injection.cr index d12f9d35d..ddbae6bdb 100644 --- a/src/components/dependency_injection/src/athena-dependency_injection.cr +++ b/src/components/dependency_injection/src/athena-dependency_injection.cr @@ -130,13 +130,27 @@ module Athena::DependencyInjection Fiber.current.container end + # Namespace for DI extension related types. + module Extension; end + + # Primary entrypoint for configuring `ADI::Extension::Schema`s. macro configure(config) {% CONFIGS << config %} end - # :nodoc: + # Adds a compiler *pass*, optionally of a specific *type* and *priority* (default `0`). + # + # Valid types include: + # + # * `:before_optimization` (default) + # * `:optimization` + # * `:before_removing` + # * `:after_removing` + # * `:removing` + # + # EXPERIMENTAL: This feature is intended for internal/advanced use and, for now, comes with limited public documentation. macro add_compiler_pass(pass, type = nil, priority = nil) {% pass_type = pass.resolve @@ -156,7 +170,7 @@ module Athena::DependencyInjection %} end - # :nodoc: + # Registers an extension `ADI::Extension::Schema` with the provided *name*. macro register_extension(name, schema) {% ADI::ServiceContainer::EXTENSIONS[name.id.stringify] = schema %} end diff --git a/src/components/dependency_injection/src/compiler_passes/process_bindings.cr b/src/components/dependency_injection/src/compiler_passes/process_bindings.cr index 04d8ee4da..082f449ac 100644 --- a/src/components/dependency_injection/src/compiler_passes/process_bindings.cr +++ b/src/components/dependency_injection/src/compiler_passes/process_bindings.cr @@ -12,7 +12,7 @@ module Athena::DependencyInjection::ServiceContainer::ProcessBindings # Typed binding BINDINGS.keys.select(&.is_a?(TypeDeclaration)).each do |key| - if key.var.id == param["arg"].name.id && (type = param["resolved_restriction"]) && key.type.resolve >= type + if key.var.id == param["name"].id && (type = param["resolved_restriction"]) && key.type.resolve >= type set_value = true definition["bindings"][name.id] = BINDINGS[key] end @@ -20,7 +20,7 @@ module Athena::DependencyInjection::ServiceContainer::ProcessBindings # Untyped binding BINDINGS.keys.select(&.!.is_a?(TypeDeclaration)).each do |key| - if key.id == param["arg"].name.id && !set_value + if key.id == param["name"].id && !set_value # Only set a value if one was not already set via a typed binding definition["bindings"][name.id] = BINDINGS[key] end diff --git a/src/components/dependency_injection/src/extension.cr b/src/components/dependency_injection/src/extension.cr index 25243df0b..015a870f1 100644 --- a/src/components/dependency_injection/src/extension.cr +++ b/src/components/dependency_injection/src/extension.cr @@ -1,3 +1,35 @@ +# Used to denote a module as an extension schema. +# Defines the configuration properties exposed to compile passes added via `ADI.add_compiler_pass`. +# Schemas must be registered via `ADI.register_extension`. +# +# EXPERIMENTAL: This feature is intended for internal/advanced use and, for now, comes with limited public documentation. +# +# ## Member Markup +# +# `#object_of` and `#array_of` support a special doc comment markup that can be used to better document each member of the objects. +# The markup consists of `---` to denote the start and end of the block. +# `>>` denotes the start of the docs for a specific property. +# The name of the property followed by a `:` should directly follow. +# From there, any text will be attributed to that property, until the next `>>` or `---`. +# Not all properties need to be included. +# +# For example: +# +# ``` +# module Schema +# include ADI::Extension::Schema +# +# # Represents a connection to the database. +# # +# # --- +# # >>username: The username, should be set to `admin` for elevated privileges. +# # >>port: Defaults to the default PG port. +# # --- +# object_of? connection, username : String, password : String, port : Int32 = 5432 +# end +# ``` +# +# WARNING: The custom markup is only supported when using `mkdocs` with some custom templates. module Athena::DependencyInjection::Extension::Schema macro included # :nodoc: @@ -7,6 +39,25 @@ module Athena::DependencyInjection::Extension::Schema CONFIG_DOCS = [] of Nil end + # Defines a schema property via the provided [declaration](https://crystal-lang.org/api/Crystal/Macros/TypeDeclaration.html). + # The type may be any primitive Crystal type (String, Bool, Array, Hash, Enum, Number, etc). + # + # ``` + # module Schema + # include ADI::Extension::Schema + # + # property enabled : Bool = true + # property name : String + # end + # + # ADI.register_extension "test", Schema + # + # ADI.configure({ + # test: { + # name: "Fred", + # }, + # }) + # ``` macro property(declaration) {% __nil = nil @@ -15,6 +66,7 @@ module Athena::DependencyInjection::Extension::Schema # I.e. to make it so you do not have to retype the type if its long/complex default = if declaration.type.resolve <= Array && !declaration.value.is_a?(Nop) && + declaration.value.is_a?(ArrayLiteral) && (array_type = ((declaration.value.of || declaration.value.type))) && !array_type.is_a?(Nop) && array_type.resolve == NoReturn.resolve @@ -31,10 +83,34 @@ module Athena::DependencyInjection::Extension::Schema abstract def {{declaration.var.id}} : {{declaration.type.id}} end + # Defines a required strictly typed `NamedTupleLiteral` object with the provided *name* and *members*. + # The members consist of a variadic list of [declarations](https://crystal-lang.org/api/Crystal/Macros/TypeDeclaration.html), with optional default values. + # ``` + # module Schema + # include ADI::Extension::Schema + # + # object_of connection, + # username : String, + # password : String, + # hostname : String = "localhost", + # port : Int32 = 5432 + # end + # + # ADI.register_extension "test", Schema + # + # ADI.configure({ + # test: { + # connection: {username: "admin", password: "abc123"}, + # }, + # }) + # ``` + # + # This macro is preferred over a direct `NamedTuple` type as it allows default values to be defined, and for the members to be documented via the special [Member Markup][Athena::DependencyInjection::Extension::Schema--member-markup] macro object_of(name, *members) process_object_of({{name}}, {{members.splat}}, nilable: false) end + # Same as `#object_of` but makes the object optional, defaulting to `nil`. macro object_of?(name, *members) process_object_of({{name}}, {{members.splat}}, nilable: true) end @@ -83,7 +159,7 @@ module Athena::DependencyInjection::Extension::Schema members.each_with_index do |m, idx| m.raise "All members must be `TypeDeclaration`s." unless m.is_a? TypeDeclaration member_map[m.var.id] = m - members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{(member_doc_map[m.var.stringify] || "NONE").strip.strip.gsub(/"/, "\\\"").id}"}) + members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{(member_doc_map[m.var.stringify] || "").strip.strip.gsub(/"/, "\\\"").id}"}) members_string += "," unless idx == members.size - 1 end members_string += "]" @@ -96,11 +172,34 @@ module Athena::DependencyInjection::Extension::Schema abstract def {{name.id}} end - # An array of a complex type + # Similar to `#object_of`, but defines an array of objects. + # ``` + # module Schema + # include ADI::Extension::Schema + # + # array_of rules, + # path : String, + # value : String + # end + # + # ADI.register_extension "test", Schema + # + # ADI.configure({ + # test: { + # rules: [ + # {path: "/foo", value: "foo"}, + # {path: "/bar", value: "bar"}, + # ], + # }, + # }) + # ``` + # + # If not provided, the property defaults to an empty array. macro array_of(name, *members) process_array_of({{name}}, {{members.splat}}, nilable: false) end + # Same as `#array_of` but makes the default value of the property `nil`. macro array_of?(name, *members) process_array_of({{name}}, {{members.splat}}, nilable: true) end @@ -146,10 +245,11 @@ module Athena::DependencyInjection::Extension::Schema members_string = "[" member_map = {__nil: nil} + members.each_with_index do |m, idx| m.raise "All members must be `TypeDeclaration`s." unless m.is_a? TypeDeclaration member_map[m.var.id] = m - members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{(member_doc_map[m.var.stringify] || "NONE").strip.strip.gsub(/"/, "\\\"").id}"}) + members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{(member_doc_map[m.var.stringify] || "").strip.strip.gsub(/"/, "\\\"").id}"}) members_string += "," unless idx == members.size - 1 end members_string += "]" diff --git a/src/components/dependency_injection/src/service_container.cr b/src/components/dependency_injection/src/service_container.cr index b07e3d529..7a5aeaaef 100644 --- a/src/components/dependency_injection/src/service_container.cr +++ b/src/components/dependency_injection/src/service_container.cr @@ -44,7 +44,6 @@ class Athena::DependencyInjection::ServiceContainer ProcessAutoConfigurations, ValidateGenerics, ], - }, # Prepare the services for usage by resolving arguments, parameters, and ensure validity of each service diff --git a/src/components/framework/spec/bundle_spec.cr b/src/components/framework/spec/bundle_spec.cr new file mode 100644 index 000000000..f25a9c8c7 --- /dev/null +++ b/src/components/framework/spec/bundle_spec.cr @@ -0,0 +1,72 @@ +require "./spec_helper" + +private def assert_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_error message, <<-CR, line: line + require "./spec_helper.cr" + #{code} + CR +end + +private def assert_success(code : String, *, codegen : Bool = false, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_success <<-CR, line: line, codegen: codegen + require "./spec_helper.cr" + #{code} + CR +end + +describe ATH::Bundle, tags: "compiled" do + describe ATH::Listeners::CORS do + it "wildcard allow_headers with allow_credentials" do + assert_error "'expose_headers' cannot contain a wildcard ('*') when 'allow_credentials' is 'true'.", <<-CODE + ATH.configure({ + framework: { + cors: { + enabled: true, + defaults: { + allow_credentials: true, + expose_headers: ["*"], + }, + }, + }, + }) + CODE + end + + it "does not exist if not enabled" do + assert_error "undefined method 'athena_framework_listeners_cors'", <<-CODE + ADI.container.athena_framework_listeners_cors + CODE + end + + # TODO: Is there a better way to test bundle extension logic? + it "correctly wires up the listener based on its configuration" do + assert_success <<-CODE, codegen: true + ATH.configure({ + framework: { + cors: { + enabled: true, + defaults: { + allow_credentials: true, + allow_origin: ["allow_origin", /foo/], + allow_headers: ["allow_headers"], + allow_methods: ["allow_methods"], + expose_headers: ["expose_headers"], + max_age: 123 + }, + }, + }, + }) + + it do + config = ADI.container.athena_framework_listeners_cors.@config + config.allow_credentials?.should be_true + config.allow_origin.should eq ["allow_origin", /foo/] + config.allow_headers.should eq ["allow_headers"] + config.allow_methods.should eq ["allow_methods"] + config.expose_headers.should eq ["expose_headers"] + config.max_age.should eq 123 + end + CODE + end + end +end diff --git a/src/components/framework/spec/listeners/cors_listener_spec.cr b/src/components/framework/spec/listeners/cors_spec.cr similarity index 100% rename from src/components/framework/spec/listeners/cors_listener_spec.cr rename to src/components/framework/spec/listeners/cors_spec.cr diff --git a/src/components/framework/spec/listeners/error_listener_spec.cr b/src/components/framework/spec/listeners/error_spec.cr similarity index 100% rename from src/components/framework/spec/listeners/error_listener_spec.cr rename to src/components/framework/spec/listeners/error_spec.cr diff --git a/src/components/framework/spec/listeners/format_listener_spec.cr b/src/components/framework/spec/listeners/format_spec.cr similarity index 68% rename from src/components/framework/spec/listeners/format_listener_spec.cr rename to src/components/framework/spec/listeners/format_spec.cr index d27b33cc8..6543be00f 100644 --- a/src/components/framework/spec/listeners/format_listener_spec.cr +++ b/src/components/framework/spec/listeners/format_spec.cr @@ -7,11 +7,8 @@ struct FormatListenerTest < ASPEC::TestCase request_store = ATH::RequestStore.new request_store.request = event.request - rules = [ - ATH::View::FormatNegotiator::Rule.new(fallback_format: "xml"), - ] - - negotiator = ATH::View::FormatNegotiator.new request_store, rules + negotiator = ATH::View::FormatNegotiator.new request_store + negotiator.add self.request_matcher(/\/test/), ATH::View::FormatNegotiator::Rule.new(fallback_format: "xml") listener = ATH::Listeners::Format.new negotiator @@ -21,6 +18,8 @@ struct FormatListenerTest < ASPEC::TestCase event.request.attributes.get?("media_type").should eq "text/xml" end + # TODO: Supports zones? + def test_stop_listener : Nil event = new_request_event event.request.request_format = "xml" @@ -28,12 +27,9 @@ struct FormatListenerTest < ASPEC::TestCase request_store = ATH::RequestStore.new request_store.request = event.request - rules = [ - ATH::View::FormatNegotiator::Rule.new(stop: true), - ATH::View::FormatNegotiator::Rule.new(fallback_format: "json"), - ] - - negotiator = ATH::View::FormatNegotiator.new request_store, rules + negotiator = ATH::View::FormatNegotiator.new request_store + negotiator.add self.request_matcher(/\/test/), ATH::View::FormatNegotiator::Rule.new(stop: true) + negotiator.add self.request_matcher(/\/test/), ATH::View::FormatNegotiator::Rule.new(fallback_format: "json") listener = ATH::Listeners::Format.new negotiator @@ -49,9 +45,7 @@ struct FormatListenerTest < ASPEC::TestCase request_store = ATH::RequestStore.new request_store.request = event.request - rules = Array(ATH::View::FormatNegotiator::Rule).new - - negotiator = ATH::View::FormatNegotiator.new request_store, rules + negotiator = ATH::View::FormatNegotiator.new request_store listener = ATH::Listeners::Format.new negotiator @@ -72,11 +66,8 @@ struct FormatListenerTest < ASPEC::TestCase request_store = ATH::RequestStore.new request_store.request = event.request - rules = [ - ATH::View::FormatNegotiator::Rule.new(fallback_format: "xml"), - ] - - negotiator = ATH::View::FormatNegotiator.new request_store, rules + negotiator = ATH::View::FormatNegotiator.new request_store + negotiator.add self.request_matcher(/\/test/), ATH::View::FormatNegotiator::Rule.new(fallback_format: "xml") listener = ATH::Listeners::Format.new negotiator @@ -92,4 +83,8 @@ struct FormatListenerTest < ASPEC::TestCase {"html", "html", nil}, } end + + private def request_matcher(path : Regex) : ATH::RequestMatcher::Interface + ATH::RequestMatcher.new ATH::RequestMatcher::Path.new path + end end diff --git a/src/components/framework/spec/listeners/param_fetcher_listener_spec.cr b/src/components/framework/spec/listeners/param_fetcher_spec.cr similarity index 100% rename from src/components/framework/spec/listeners/param_fetcher_listener_spec.cr rename to src/components/framework/spec/listeners/param_fetcher_spec.cr diff --git a/src/components/framework/spec/listeners/view_listener_spec.cr b/src/components/framework/spec/listeners/view_spec.cr similarity index 100% rename from src/components/framework/spec/listeners/view_listener_spec.cr rename to src/components/framework/spec/listeners/view_spec.cr diff --git a/src/components/framework/spec/spec_helper.cr b/src/components/framework/spec/spec_helper.cr index c27a32228..ed612b049 100644 --- a/src/components/framework/spec/spec_helper.cr +++ b/src/components/framework/spec/spec_helper.cr @@ -10,12 +10,6 @@ require "athena-console/spec" require "athena-validator/spec" require "../src/spec" -ATH.configure({ - parameters: { - "framework.debug": true, - }, -}) - Spec.before_each do ART.compile ATH::Routing::AnnotationRouteLoader.route_collection end diff --git a/src/components/framework/spec/view/format_negotiator_spec.cr b/src/components/framework/spec/view/format_negotiator_spec.cr index a88d19102..08e731329 100644 --- a/src/components/framework/spec/view/format_negotiator_spec.cr +++ b/src/components/framework/spec/view/format_negotiator_spec.cr @@ -1,20 +1,27 @@ require "../spec_helper" +private class MockRequestMatcher + include ATH::RequestMatcher::Interface + + def initialize(@matches : Bool); end + + def matches?(request : ATH::Request) : Bool + @matches + end +end + struct FormatNegotiatorTest < ASPEC::TestCase @request_store : ATH::RequestStore @request : ATH::Request @negotiator : ATH::View::FormatNegotiator - @rules : Array(ATH::View::FormatNegotiator::Rule) def initialize @request_store = ATH::RequestStore.new @request = ATH::Request.new "GET", "/" @request_store.request = @request - @rules = [] of ATH::View::FormatNegotiator::Rule @negotiator = ATH::View::FormatNegotiator.new( @request_store, - @rules, {"json" => ["application/json;version=1.0"]} ) end @@ -24,7 +31,7 @@ struct FormatNegotiatorTest < ASPEC::TestCase end def test_best_stop_exception : Nil - self.add_rule + self.add_rule false self.add_rule stop: true expect_raises ATH::Exceptions::StopFormatListener, "Stopping format listener." do @@ -77,28 +84,39 @@ struct FormatNegotiatorTest < ASPEC::TestCase @negotiator.best("").should eq ANG::Accept.new "application/json" end - def test_best_undesired_path : Nil - @request.headers["accept"] = "text/html" - @request.path = "/user" - self.add_rule priorities: ["html", "json"], fallback_format: "json", path: /^\/admin/ - @negotiator.best("").should be_nil - end + def test_best_with_prefer_extension : Nil + priorities = ["text/html", "application/json"] + self.add_rule priorities: priorities, prefer_extension: true - def test_best_undesired_method : Nil - @request.headers["accept"] = "text/html" - @request.method = "POST" - self.add_rule priorities: ["html", "json"], fallback_format: "json", methods: ["GET"] - @negotiator.best("").should be_nil + @request.path = "/file.json" + + # Without extension mime-type in accept header + + @request.headers["accept"] = "text/html; q=1.0" + @negotiator.best("").should eq ANG::Accept.new "application/json" + + # With low q extension mime-type in accept header + + @request.headers["accept"] = "text/html; q=1.0, application/json; q=0.1" + @negotiator.best("").should eq ANG::Accept.new "application/json" end - def test_best_undesired_host : Nil - @request.headers["accept"] = "text/html" - @request.headers["host"] = "app.domain.com" - self.add_rule priorities: ["html", "json"], fallback_format: "json", host: /api\.domain\.com/ - @negotiator.best("").should be_nil + def test_best_with_prefer_extension_and_unknown_extension : Nil + priorities = ["text/html", "application/json"] + self.add_rule priorities: priorities, prefer_extension: true + + @request.path = "/file.123456789" + + # Without extension mime-type in accept header + + @request.headers["accept"] = "text/html, application/json" + @negotiator.best("").should eq ANG::Accept.new "text/html" end - private def add_rule(**args) - @rules << ATH::View::FormatNegotiator::Rule.new **args + private def add_rule(match : Bool = true, **args) + rule = ATH::View::FormatNegotiator::Rule.new **args + matcher = MockRequestMatcher.new match + + @negotiator.add matcher, rule end end diff --git a/src/components/framework/src/abstract_bundle.cr b/src/components/framework/src/abstract_bundle.cr index 5e8f5f2a0..6ab27eade 100644 --- a/src/components/framework/src/abstract_bundle.cr +++ b/src/components/framework/src/abstract_bundle.cr @@ -4,6 +4,9 @@ module Athena::Framework PASSES = [] of Nil end + # Registers the provided *bundle* with the framework. + # + # See the [Getting Started](/getting_started/configuration) docs for more information. macro register_bundle(bundle) {% resolved_bundle = bundle.resolve @@ -23,6 +26,11 @@ module Athena::Framework {% end %} end + # Primary entrypoint for configuring Athena Framework applications. + # + # See the [Getting Started](/getting_started/configuration) docs for more information. + # + # NOTE: This is an alias of [ADI.configure](/DependencyInjection/top_level/#Athena::DependencyInjection:configure(config)). macro configure(config) ADI.configure({{config}}) end diff --git a/src/components/framework/src/athena.cr b/src/components/framework/src/athena.cr index d6a2e8c16..c7a9f1b16 100755 --- a/src/components/framework/src/athena.cr +++ b/src/components/framework/src/athena.cr @@ -38,7 +38,6 @@ require "./compiler_passes/*" require "./events/*" require "./exceptions/*" require "./listeners/*" -require "./parameters/*" require "./params/*" require "./request_matcher/*" require "./view/*" @@ -47,7 +46,6 @@ require "./ext/clock" require "./ext/console" require "./ext/conversion_types" require "./ext/event_dispatcher" -require "./ext/negotiation" require "./ext/routing" require "./ext/validator" @@ -222,9 +220,3 @@ module Athena::Framework end ATH.register_bundle ATH::Bundle - -ATH.configure({ - parameters: { - "framework.debug": true, - }, -}) diff --git a/src/components/framework/src/bundle.cr b/src/components/framework/src/bundle.cr index 8d6493ecd..7ced53d7f 100644 --- a/src/components/framework/src/bundle.cr +++ b/src/components/framework/src/bundle.cr @@ -1,19 +1,34 @@ @[Athena::Framework::Annotations::Bundle("framework")] +# The Athena Framework Bundle is responsible for integrating the various Athena components into the Athena Framework. +# This primarily involves wiring up various types as services, and other DI related tasks. struct Athena::Framework::Bundle < Athena::Framework::AbstractBundle + # :nodoc: PASSES = [ {Athena::Framework::CompilerPasses::MakeControllerServicesPublicPass, nil, nil}, {Athena::Framework::Console::CompilerPasses::RegisterCommands, :before_removing, nil}, {Athena::Framework::EventDispatcher::CompilerPasses::RegisterEventListenersPass, :before_removing, nil}, ] - # Represents the possible configuration properties, including their name, type, default, and documentation. + # Represents the possible properties used to configure and customize Athena Framework features. + # See the [Getting Started](/getting_started/configuration) docs for more information. module Schema include ADI::Extension::Schema + # The default locale is used if no [_locale](/Routing/Route/#Athena::Routing::Route--special-parameters) routing parameter has been set. + # It is available with the Request::getDefaultLocale method. + property default_locale : String = "en" + + # Configuration related to the `ATH::Listeners::Format` listener. + # + # If enabled, the rules are used to determine the best format for the current request based on its + # [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) header. + # + # `ATH::Request::FORMATS` is used to map the request's `MIME` type to its format. module FormatListener include ADI::Extension::Schema - property? enabled : Bool = false + # If `false`, the format listener will be disabled and not included in the resulting binary. + property enabled : Bool = false # The rules used to determine the best format. # Rules should be defined in priority order, with the highest priority having index 0. @@ -21,7 +36,7 @@ struct Athena::Framework::Bundle < Athena::Framework::AbstractBundle # ### Example # # ``` - # ADI.configure({ + # ATH.configure({ # framework: { # format_listener: { # enabled: true, @@ -65,12 +80,12 @@ struct Athena::Framework::Bundle < Athena::Framework::AbstractBundle prefer_extension : Bool = true end - # Configured how `ATH::Listeners::CORS` functions. + # Configures how `ATH::Listeners::CORS` functions. # If no configuration is provided, that listener is disabled and will not be invoked at all. module Cors include ADI::Extension::Schema - property? enabled : Bool = false + property enabled : Bool = false # CORS defaults that affect all routes globally. module Defaults @@ -79,34 +94,243 @@ struct Athena::Framework::Bundle < Athena::Framework::AbstractBundle # Indicates whether the request can be made using credentials. # # Maps to the access-control-allow-credentials header. - property? allow_credentials : Bool = false + property allow_credentials : Bool = false # A white-listed array of valid origins. Each origin may be a static String, or a Regex. # # Can be set to ["*"] to allow any origin. - property allow_origin : Array(String) = [] of String + property allow_origin : Array(String | Regex) = [] of String | Regex + + # The header or headers that can be used when making the actual request. + # + # Can be set to `["*"]` to allow any headers. + # + # maps to the `access-control-allow-headers` header. + property allow_headers : Array(String) = [] of String # Array of headers that the browser is allowed to read from the response. # # Maps to the access-control-expose-headers header. property expose_headers : Array(String) = [] of String + + # The method(s) allowed when accessing the resource. + # + # Maps to the `access-control-allow-methods` header. + # Defaults to the [CORS-safelisted methods](https://fetch.spec.whatwg.org/#cors-safelisted-method). + property allow_methods : Array(String) = ATH::Listeners::CORS::SAFELISTED_METHODS + + # Number of seconds that the results of a preflight request can be cached. + # + # Maps to the `access-control-max-age header`. + property max_age : Int32 = 0 end end + + module Router + include ADI::Extension::Schema + + # The default URI used to generate URLs in non-HTTP contexts. + # See the [Getting Started](/getting_started/routing/#in-commands) docs for more information. + property default_uri : String? = nil + + # The default HTTP port when generating URLs. + # See the [Getting Started](/getting_started/routing/#url-generation) docs for more information. + property http_port : Int32 = 80 + + # The default HTTPS port when generating URLs. + # See the [Getting Started](/getting_started/routing/#url-generation) docs for more information. + property https_port : Int32 = 443 + + # Determines how invalid parameters should be treated when [Generating URLs](/getting_started/routing/#url-generation): + # + # * `true` - Raise an exception for mismatched requirements. + # * `false` - Do not raise an exception, but return an empty string. + # * `nil` - Disables checks, returning a URL with possibly invalid parameters. + property strict_requirements : Bool? = true + end + + module ViewHandler + include ADI::Extension::Schema + + # If `nil` values should be serialized. + property serialize_nil : Bool = false + + # The `HTTP::Status` used when there is no response content. + property empty_content_status : HTTP::Status = :no_content + + # The `HTTP::Status` used when validations fail. + # + # Currently not used. Included for future work. + property failed_validation_status : HTTP::Status = :unprocessable_entity + end end + # :nodoc: module Extension macro included macro finished {% verbatim do %} + # Built-in parameters + {% + cfg = CONFIG["framework"] + parameters = CONFIG["parameters"] + + parameters["framework.default_locale"] = cfg["default_locale"] + + debug = parameters["framework.debug"] + + # If no debug parameter was already configured, try and determine an appropriate value: + # * true if configured explicitly via ENV var + # * true if env ENV var is present and not production + # * true if not compiled with --release + # + # This should default to `false`, except explicitly set otherwise + if debug.nil? + release_flag = flag?(:release) + debug_env = env("ATHENA_DEBUG") == "true" + non_prod_env = env("ATHENA_ENV") != "production" + + parameters["framework.debug"] = debug_env || non_prod_env || !flag?(:release) + end + %} + + # CORS Listener + {% + cfg = CONFIG["framework"]["cors"] + + if cfg["enabled"] + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers + if cfg["defaults"]["allow_credentials"] && cfg["defaults"]["expose_headers"].includes? "*" + cfg["defaults"]["expose_headers"].raise "'expose_headers' cannot contain a wildcard ('*') when 'allow_credentials' is 'true'." + end + + # TODO: Support multiple paths + config = <<-CRYSTAL + ATH::Listeners::CORS::Config.new( + allow_credentials: #{cfg["defaults"]["allow_credentials"]}, + allow_origin: #{cfg["defaults"]["allow_origin"]}, + allow_headers: #{cfg["defaults"]["allow_headers"]}, + allow_methods: #{cfg["defaults"]["allow_methods"]}, + expose_headers: #{cfg["defaults"]["expose_headers"]}, + max_age: #{cfg["defaults"]["max_age"]} + ) + CRYSTAL + + SERVICE_HASH["athena_framework_listeners_cors"] = { + class: ATH::Listeners::CORS, + parameters: { + # TODO: Consider having some other service responsible for resolving the config obj + config: {value: config.id}, + }, + } + end + %} + + # Routing + {% + parameters = CONFIG["parameters"] + cfg = CONFIG["framework"]["router"] + + parameters["framework.router.request_context.host"] = "localhost" + parameters["framework.router.request_context.scheme"] = "http" + parameters["framework.router.request_context.base_url"] = "" + parameters["framework.request_listener.http_port"] = cfg["http_port"] + parameters["framework.request_listener.https_port"] = cfg["https_port"] + + # TODO: Make this `default_router` with a public alias of `router` instead. + SERVICE_HASH[router_id = "router"] = { + class: ATH::Routing::Router, + aliases: [ART::Generator::Interface, ART::Matcher::URLMatcherInterface, ART::RouterInterface], + public: true, + parameters: { + default_locale: {value: "%framework.default_locale%"}, + strict_requirements: {value: cfg["strict_requirements"]}, + }, + } + + SERVICE_HASH[request_context_id = "athena_routing_request_context"] = { + class: ART::RequestContext, + factory: {ART::RequestContext, "from_uri"}, + parameters: { + uri: {value: "%framework.router.request_context.base_url%"}, + host: {value: "%framework.router.request_context.host%"}, + scheme: {value: "%framework.router.request_context.scheme%"}, + http_port: {value: "%framework.request_listener.http_port%"}, + https_port: {value: "%framework.request_listener.https_port%"}, + }, + } + + if default_uri = cfg["default_uri"] + SERVICE_HASH[request_context_id]["parameters"]["uri"]["value"] = default_uri + end + %} # Format Listener {% cfg = CONFIG["framework"]["format_listener"] if cfg["enabled"] && !cfg["rules"].empty? - # pp "Yup" + matcher_arguments_to_service_id_map = {} of Nil => Nil + + map = [] of Nil + + cfg["rules"].each_with_index do |rule, idx| + matcher_id = {rule["path"], rule["host"], rule["methods"], nil}.symbolize + + # Optimization to allow reusing request matcher instances that are common between the rules. + if matcher_arguments_to_service_id_map[matcher_id] == nil + matchers = [] of Nil + + if v = rule["path"] + matchers << "ATH::RequestMatcher::Path.new(#{v})".id + end + + if v = rule["host"] + matchers << "ATH::RequestMatcher::Host.new(#{v})".id + end + + if v = rule["methods"] + matchers << "ATH::RequestMatcher::Method.new(#{v})".id + end + + SERVICE_HASH[matcher_service_id = "framework_view_handler_request_match_#{idx}"] = { + class: ATH::RequestMatcher, + parameters: { + matchers: {value: "#{matchers} of ATH::RequestMatcher::Interface".id}, + }, + } + + matcher_arguments_to_service_id_map[matcher_id] = matcher_service_id + else + matcher_service_id = matcher_arguments_to_service_id_map[matcher_id] + end + + map << %({#{matcher_service_id.id}, ATH::View::FormatNegotiator::Rule.new(#{rule.double_splat})}).id + end + + SERVICE_HASH["athena_framework_listeners_format"] = { + class: ATH::View::FormatNegotiator, + factory: {ATH::View::FormatNegotiator, "create"}, + parameters: { + map: {value: map.id}, + }, + } end %} + + # View Handler + {% + cfg = CONFIG["framework"]["view_handler"] + + SERVICE_HASH["athena_framework_view_view_handler"] = { + class: ATH::View::ViewHandler, + parameters: { + emit_nil: {value: cfg["serialize_nil"]}, + failed_validation_status: {value: cfg["failed_validation_status"]}, + empty_content_status: {value: cfg["empty_content_status"]}, + }, + } + %} {% end %} end end diff --git a/src/components/framework/src/ext/negotiation.cr b/src/components/framework/src/ext/negotiation.cr deleted file mode 100644 index 36e035645..000000000 --- a/src/components/framework/src/ext/negotiation.cr +++ /dev/null @@ -1,2 +0,0 @@ -@[ADI::Register] -class ANG::Negotiator; end diff --git a/src/components/framework/src/ext/routing/router.cr b/src/components/framework/src/ext/routing/router.cr index a27a1f71b..cf51b8a80 100644 --- a/src/components/framework/src/ext/routing/router.cr +++ b/src/components/framework/src/ext/routing/router.cr @@ -1,5 +1,4 @@ # :nodoc: -@[ADI::Register(_default_uri: "%routing.base_uri%", name: "router", public: true, alias: [ART::Generator::Interface, ART::Matcher::URLMatcherInterface, ART::RouterInterface])] class Athena::Framework::Routing::Router < Athena::Routing::Router getter matcher : ART::Matcher::URLMatcherInterface do ATH::Routing::RedirectableURLMatcher.new(@context) @@ -7,13 +6,14 @@ class Athena::Framework::Routing::Router < Athena::Routing::Router def initialize( default_locale : String? = nil, - strict_requirements : Bool = true, - base_uri : String? = nil + strict_requirements : Bool? = true, + request_context : ART::RequestContext? = nil ) - super(ATH::Routing::AnnotationRouteLoader.route_collection, + super( + ATH::Routing::AnnotationRouteLoader.route_collection, default_locale, strict_requirements, - base_uri.try { |uri| ART::RequestContext.from_uri uri } + request_context, ) end end diff --git a/src/components/framework/src/listeners/cors_listener.cr b/src/components/framework/src/listeners/cors.cr similarity index 94% rename from src/components/framework/src/listeners/cors_listener.cr rename to src/components/framework/src/listeners/cors.cr index c066ba104..88be7026a 100755 --- a/src/components/framework/src/listeners/cors_listener.cr +++ b/src/components/framework/src/listeners/cors.cr @@ -1,4 +1,3 @@ -@[ADI::Register] # Supports [Cross-Origin Resource Sharing](https://enable-cors.org) (CORS) requests. # # Handles CORS preflight `OPTIONS` requests as well as adding CORS headers to each response. @@ -8,6 +7,7 @@ struct Athena::Framework::Listeners::CORS include AED::EventListenerInterface + # :nodoc: struct Config getter? allow_credentials : Bool getter allow_origin : Array(String | Regex) @@ -24,11 +24,6 @@ struct Athena::Framework::Listeners::CORS @expose_headers : Array(String) = [] of String, @max_age : Int32 = 0 ) - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers - # if @allow_credentials && @expose_headers.includes? "*" - # raise ArgumentError.new "expose_headers cannot contain a wildcard ('*') when allow_credentials is 'true'." - # end - @allow_origin = allow_origin.map &.as String | Regex end end @@ -63,7 +58,7 @@ struct Athena::Framework::Listeners::CORS private EXPOSE_HEADERS_HEADER = "access-control-expose-headers" private MAX_AGE_HEADER = "access-control-max-age" - def initialize(@config : ATH::Listeners::CORS::Config = ATH::Listeners::CORS::Config.new); end + protected def initialize(@config : ATH::Listeners::CORS::Config = ATH::Listeners::CORS::Config.new); end @[AEDA::AsEventListener(priority: 250)] def on_request(event : ATH::Events::Request) : Nil diff --git a/src/components/framework/src/listeners/error_listener.cr b/src/components/framework/src/listeners/error.cr similarity index 100% rename from src/components/framework/src/listeners/error_listener.cr rename to src/components/framework/src/listeners/error.cr diff --git a/src/components/framework/src/listeners/format_listener.cr b/src/components/framework/src/listeners/format.cr similarity index 98% rename from src/components/framework/src/listeners/format_listener.cr rename to src/components/framework/src/listeners/format.cr index 13e02e43a..51c3f0384 100644 --- a/src/components/framework/src/listeners/format_listener.cr +++ b/src/components/framework/src/listeners/format.cr @@ -1,6 +1,5 @@ require "mime" -# @[ADI::Register] # Attempts to determine the best format for the current request based on its [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) `HTTP` header # and the format priority configuration. # diff --git a/src/components/framework/src/listeners/param_fetcher_listener.cr b/src/components/framework/src/listeners/param_fetcher.cr similarity index 100% rename from src/components/framework/src/listeners/param_fetcher_listener.cr rename to src/components/framework/src/listeners/param_fetcher.cr diff --git a/src/components/framework/src/listeners/routing_listener.cr b/src/components/framework/src/listeners/routing.cr similarity index 100% rename from src/components/framework/src/listeners/routing_listener.cr rename to src/components/framework/src/listeners/routing.cr diff --git a/src/components/framework/src/listeners/view_listener.cr b/src/components/framework/src/listeners/view.cr similarity index 100% rename from src/components/framework/src/listeners/view_listener.cr rename to src/components/framework/src/listeners/view.cr diff --git a/src/components/framework/src/parameters/parameters.cr b/src/components/framework/src/parameters/parameters.cr deleted file mode 100644 index 805b18eee..000000000 --- a/src/components/framework/src/parameters/parameters.cr +++ /dev/null @@ -1,47 +0,0 @@ -# Encompasses parameters related to the `Athena::Framework` component. -struct Athena::Framework::Parameters - # This method should be overridden in order to customize the parameters for the `Athena::Framework` component. - # - # ``` - # # Returns an `ATH::Parameters` instance with customized parameter values. - # def ATH::Parameters.configure - # new( - # base_uri: "https://myapp.com", - # ) - # end - # ``` - def self.configure : self - new - end - - # Returns an optional `URI` instance for use within `ART::Generator::Interface#generate`. - getter base_uri : URI? - - def initialize( - base_uri : URI | String | Nil = nil - ) - @base_uri = base_uri.is_a?(String) ? URI.parse base_uri : base_uri - - @base_uri.try do |uri| - raise ArgumentError.new "The base_uri must include a scheme." if uri.scheme.nil? - end - end - - struct Framework - def self.configure : self - new - end - - # Returns `true` if the application was built without the `--release` flag, otherwise `false`. - getter debug : Bool = {{!flag? :release}} - end -end - -class Athena::Config::Parameters - getter routing : ATH::Parameters = ATH::Parameters.configure - getter framework : ATH::Parameters::Framework = ATH::Parameters::Framework.configure -end - -# Setup bindings for built in parameters. -ADI.bind base_uri : URI?, "%routing.base_uri%" -ADI.bind debug : Bool, "%framework.debug%" diff --git a/src/components/framework/src/view/format_negotiator.cr b/src/components/framework/src/view/format_negotiator.cr index 69299ce97..d8a3fdcf2 100644 --- a/src/components/framework/src/view/format_negotiator.cr +++ b/src/components/framework/src/view/format_negotiator.cr @@ -1,8 +1,8 @@ -# @[ADI::Register] # An extension of `ANG::Negotiator` that supports resolving the format based on an applications `ATH::Config::ContentNegotiation` rules. # # See the [Getting Started](/getting_started/routing#content-negotiation) docs for more information. class Athena::Framework::View::FormatNegotiator < ANG::Negotiator + # :nodoc: record Rule, path : Regex = /^\//, host : Regex? = nil, @@ -12,35 +12,48 @@ class Athena::Framework::View::FormatNegotiator < ANG::Negotiator stop : Bool = false, prefer_extension : Bool = true - @options : Array(ATH::View::FormatNegotiator::Rule) + @map : Array({ATH::RequestMatcher::Interface, ATH::View::FormatNegotiator::Rule}) = [] of {ATH::RequestMatcher::Interface, ATH::View::FormatNegotiator::Rule} + + # TODO: Handle this via `calls` on the service def + protected def self.create( + request_store : ATH::RequestStore, + map : Array({ATH::RequestMatcher::Interface, ATH::View::FormatNegotiator::Rule}), + mime_types : Hash(String, Array(String)) = Hash(String, Array(String)).new + ) : self + instance = new request_store, mime_types + + map.each do |(matcher, rule)| + instance.add matcher, rule + end + + instance + end def initialize( @request_store : ATH::RequestStore, - @options : Array(ATH::View::FormatNegotiator::Rule) = [] of ATH::View::FormatNegotiator::Rule, @mime_types : Hash(String, Array(String)) = Hash(String, Array(String)).new ) end + protected def add(request_matcher : ATH::RequestMatcher::Interface, rule : Rule) : Nil + @map << {request_matcher, rule} + end + # :inherit: # ameba:disable Metrics/CyclomaticComplexity def best(header : String, priorities : Indexable(String)? = nil, strict : Bool = false) : HeaderType? request = @request_store.request header = header.presence || request.headers["accept"]? + extension_header = nil - @options.each do |rule| - # TODO: Abstract request matching logic into a dedicated service. - next unless request.path.matches? rule.path - if methods = rule.methods - next unless methods.includes? request.method - end + @map.each do |(matcher, rule)| + next unless matcher.matches? request - if (host_pattern = rule.host) && (hostname = request.hostname) - next unless host_pattern.matches? hostname + if rule.stop + raise ATH::Exceptions::StopFormatListener.new "Stopping format listener." end - raise ATH::Exceptions::StopFormatListener.new "Stopping format listener." if rule.stop - if priorities.nil? && rule.priorities.nil? if fallback_format = rule.fallback_format request.mime_type(fallback_format.as(String)).try do |mime_type| @@ -51,14 +64,19 @@ class Athena::Framework::View::FormatNegotiator < ANG::Negotiator next end - # TODO: Support using the request path extension to determine the format. - # This would require being able to define routes like `/foo.{_format}` first however. + if rule.prefer_extension && extension_header.nil? + if (extension = Path.new(request.path).extension.lchop '.').presence + extension_header = request.mime_type extension + + header = %(#{extension_header}; q=2.0#{(h = header.presence) ? ",#{h}" : ""}) + end + end - if header + if h = header.presence # Priorities defined on the rule wont be nil at this point it would have been skipped mime_types = self.normalize_mime_types priorities || rule.priorities.not_nil! - if mime_type = super header, mime_types + if mime_type = super h, mime_types return mime_type end end @@ -71,6 +89,8 @@ class Athena::Framework::View::FormatNegotiator < ANG::Negotiator end end end + + nil end private def normalize_mime_types(priorities : Indexable(String)) : Array(String) diff --git a/src/components/routing/src/router.cr b/src/components/routing/src/router.cr index 9f320b135..961913fa7 100644 --- a/src/components/routing/src/router.cr +++ b/src/components/routing/src/router.cr @@ -26,7 +26,7 @@ class Athena::Routing::Router def initialize( @route_collection : ART::RouteCollection, @default_locale : String? = nil, - @strict_requirements : Bool = true, + @strict_requirements : Bool? = true, context : ART::RequestContext? = nil ) @context = context || ART::RequestContext.new