Skip to content

Commit

Permalink
Config v3 framework integration (#383)
Browse files Browse the repository at this point in the history
* Update docs to reference new `ATH.configure` approach to configuration
* Make the special member markup not include any docs if not included vs `NONE`
* Add specs for Framework Bundle
* Rename listeners to remove `_listener` suffix
* Integration framework features via new bundle system
* Make `ART::Router#strict_requirements` nilable
  • Loading branch information
Blacksmoke16 authored Apr 1, 2024
1 parent b6fecfe commit edd92dc
Show file tree
Hide file tree
Showing 30 changed files with 791 additions and 315 deletions.
182 changes: 161 additions & 21 deletions docs/getting_started/configuration.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,181 @@
Some features need to be configured;
either to enable/control how they work, or to customize the default functionality.

<!-- ## `ATH.configure` -->
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": "[email protected]",
# 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%",
},
})
```

<!-- ## Bundles -->
TIP: Parameters may also be [injected](/DependencyInjection/Register/#Athena::DependencyInjection::Register--parameters) directly into services via their constructor.

## Custom Annotations

Expand Down
116 changes: 85 additions & 31 deletions docs/getting_started/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -320,50 +364,60 @@ 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.

### 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
Expand Down
Loading

0 comments on commit edd92dc

Please sign in to comment.