diff --git a/.travis.yml b/.travis.yml index 5626ec0d8..a4f60760b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,11 @@ language: crystal install: - shards install + - shards build script: - bin/ameba + - crystal tool format --check - crystal spec - crystal docs diff --git a/athena.yml b/athena.yml index 4a524c502..d82a4225c 100644 --- a/athena.yml +++ b/athena.yml @@ -4,7 +4,7 @@ routing: cors: enabled: false strategy: blacklist - defaults: + defaults: &defaults allow_origin: https://yourdomain.com expose_headers: [] max_age: 0 diff --git a/docs/cli.md b/docs/cli.md index 081b68c8d..8b78654d9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,7 +10,7 @@ A command is created by defining a struct that inherits from `Athena::Cli::Comma require "athena/cli" struct MigrateEventsCommand < Athena::Cli::Command - self.command_name = "migrate:events" + self.name = "migrate:events" self.description = "Migrates legacy events for a given customer" def self.execute(customer_id : Int32, event_ids : Array(Int64)) : Nil @@ -37,11 +37,27 @@ end Then, after building the program. -```Text -./MyApp -c migrate:events --customer_id=83726 --event_ids=1,2,3,4,5 +```bash ./MyApp -l Registered commands: + migrate + migrate:events - Migrates legacy events for a given customer +./MyApp -c migrate:events --customer_id=83726 --event_ids 1,2,3,4,5 +``` + +the `-l` or `--list` argument will list the available commands that can be executed via the binary. The commands are grouped based on the first part of the command name, separated by `:`. The `-e NAME` or `--explain NAME` can be used to get more detailed information about a given command. + +Commands are executed by using the `--command NAME` or `-c NAME` syntax; where `NAME` is the name of the command. Arguments are passed via the `--key=value` or `--key value` format, where `key` matches the argument name from the `self.execute` method. + +```bash +./MyApp -e migrate:events +Command migrate:events - Migrates legacy events for a given customer +Usage + ./YOUR_BINARY -c migrate:events [arguments] +Arguments + customer_id : Int32 + event_ids : Array(Int64) ``` ### Parameters @@ -54,7 +70,7 @@ All primitive data types are supported including: `Int32`, `Bool`, `Float64`, e ### Required/Optional Parameters -Non-nilable parameters are considered required and will raise an exception if not supplied. Nilable parameters are considered optional and will be nil if not supplied. +Non-nilable parameters are considered required and will raise an exception if not supplied, without a default value. Nilable parameters are considered optional and will be nil if not supplied, without a default value. diff --git a/docs/readme.md b/docs/readme.md index 465450036..6eeaf6e57 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -2,6 +2,12 @@ Athena takes a modular approach to its feature set. Each feature is encapsulated in its own module; and can be required independently of each other. This allows an application to only include what that application needs, without extra bloat. +## The `athena` executable + +Upon install, Athena will build and add an `athena` executable to your projects `bin` directory. This binary can be used to run Athena related commands, all of which are listed in the [docs](). + +## Modules + * [Routing](./routing.md) `require "athena/routing"` - _done_: * [Defining routes](./routing.md#defining-routes) * [Exception Handling](./routing.md#exception-handling) diff --git a/shard.yml b/shard.yml index a866beb33..cc655d3e3 100644 --- a/shard.yml +++ b/shard.yml @@ -1,7 +1,7 @@ name: athena description: | - Annotation based JSON API framework with built in param conversion. + Modular, annotation based, API oriented framework with built in param conversion. version: 0.4.0 @@ -13,11 +13,14 @@ crystal: 0.27.2 license: MIT targets: - init: - main: src/config/init.cr + athena: + main: src/athena.cr scripts: - postinstall: shards build --release --production && ./bin/init + postinstall: "shards build --release --production && ./bin/athena -c athena:generate:config_file --path=../../athena.yml" + +executables: + - athena dependencies: CrSerializer: diff --git a/spec/athena_spec.cr b/spec/athena_spec.cr new file mode 100644 index 000000000..6c8f8e224 --- /dev/null +++ b/spec/athena_spec.cr @@ -0,0 +1,72 @@ +require "./spec_helper_methods" +require "../src/athena" + +COMMANDS = <<-COMMANDS +Registered Commands: +\tathena +\t\tathena:generate:config_file - Generates the default config file for Athena\n +COMMANDS + +EXPLAIN = <<-EXPLAIN +Command +\tathena:generate:config_file - Generates the default config file for Athena +Usage +\t./YOUR_BINARY -c athena:generate:config_file [arguments] +Arguments +\toverride : Bool = false +\tpath : String = "./athena.yml"\n +EXPLAIN + +describe Athena do + describe "binary" do + describe "--list -l" do + it "should list avaliable commands" do + run_binary(args: ["-l"]) do |output| + output.should eq COMMANDS + end + end + end + + describe "--explain -e" do + it "should print the help" do + run_binary(args: ["-e", "athena:generate:config_file"]) do |output| + output.should eq EXPLAIN + end + end + end + + describe Athena::Commands do + describe "athena:generate:config_file" do + describe "when the config file already exists" do + it "should not recreate the file" do + created = File.info "athena.yml" + run_binary(args: ["-c", "athena:generate:config_file"]) do |_output| + modified = File.info "athena.yml" + created.modification_time.should eq modified.modification_time + end + end + end + + describe "when using the override flag" do + it "should recreate the file" do + original = File.info "athena.yml" + run_binary(args: ["-c", "athena:generate:config_file", "--override=true"]) do |_output| + new = File.info "athena.yml" + (original.modification_time < new.modification_time).should be_true + end + end + end + + describe "when using the path flag" do + it "should create the file at the given location" do + File.exists?("#{Dir.tempdir}/athena.yml").should be_false + run_binary(args: ["-c", "athena:generate:config_file", "--path=#{Dir.tempdir}/athena.yml"]) do |_output| + File.exists?("#{Dir.tempdir}/athena.yml").should be_true + File.delete("#{Dir.tempdir}/athena.yml") + end + end + end + end + end + end +end diff --git a/spec/cli/cli_spec_helper.cr b/spec/cli/cli_spec_helper.cr index e8039a0b6..e6e426ce3 100644 --- a/spec/cli/cli_spec_helper.cr +++ b/spec/cli/cli_spec_helper.cr @@ -1,5 +1,4 @@ -require "spec" - +require "../spec_helper_methods" require "../../src/cli" require "./commands/*" diff --git a/spec/cli/command_spec.cr b/spec/cli/command_spec.cr index 750be8614..6538d1358 100644 --- a/spec/cli/command_spec.cr +++ b/spec/cli/command_spec.cr @@ -1,55 +1,96 @@ require "./cli_spec_helper" +to_s = <<-TOS +Command +\tto_s - Command to test .to_s on +Usage +\t./YOUR_BINARY -c to_s [arguments] +Arguments +\toptional : (String | Nil) +\trequired : Bool +\tpath : String = "./"\n +TOS + describe Athena::Cli::Command do describe "when parsing args from a command" do + describe "when there are none" do + it "should not try to parse arguments" do + NoParamsCommand.run_command([] of String).should eq "foo" + NoParamsCommand.run_command(["--id=123"]).should eq "foo" + NoParamsCommand.run_command(["--one=foo", "--three=3.14", "--two="]).should eq "foo" + end + end + describe "that are required" do context "with one param" do it "should convert correctly" do - CreateUserCommand.command.call(["--id=123"]).should eq 100 + CreateUserCommand.run_command(["--id=123"]).should eq 100 + CreateUserCommand.run_command(["--id 123"]).should eq 100 end it "should raise if missing" do - expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.command.call(["--id="]) } - expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.command.call(["--id"]) } - expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.command.call(["--i"]) } + expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.run_command ["--id="] } + expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.run_command ["--id"] } + expect_raises Exception, "Required argument 'id' was not supplied" { CreateUserCommand.run_command ["--i"] } end end context "with multiple params" do it "should convert correctly" do - MultiParamCommand.command.call(["--one=foo", "--three=3.14", "--two=8"]).should eq "foo is 11.14" + MultiParamCommand.run_command(["--one=foo", "--three=3.14", "--two=8"]).should eq "foo is 11.14" + MultiParamCommand.run_command(["--one=foo", "--three 3.14", "--two=8"]).should eq "foo is 11.14" end it "should raise if missing" do - expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.command.call ["--one=foo", "--three=3.14", "--two="] } - expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.command.call ["--one=foo", "--three=3.14", "--two"] } - expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.command.call ["--one=foo", "--three=3.14", "--t"] } - expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.command.call ["--one=foo", "--three=3.14"] } + expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.run_command ["--one=foo", "--three=3.14", "--two="] } + expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.run_command ["--one=foo", "--three=3.14", "--two"] } + expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.run_command ["--one=foo", "--three=3.14", "--t"] } + expect_raises Exception, "Required argument 'two' was not supplied" { MultiParamCommand.run_command ["--one=foo", "--three=3.14"] } + end + end + + context "with a default value" do + it "should use default value if no value is given" do + DefaultValueCommand.run_command([] of String).should eq "./" + end + end + + context "without a default value" do + it "should use given value" do + DefaultValueCommand.run_command(["--path=/user/config"]).should eq "/user/config" + DefaultValueCommand.run_command(["--path /user/config"]).should eq "/user/config" end end context "with an array param" do it "should convert correctly" do - ArrayBoolCommand.command.call(["--bools=true,false,false,true"]).should eq [true, false, false, true] + ArrayBoolCommand.run_command(["--bools=true,false,false,true"]).should eq [true, false, false, true] + ArrayBoolCommand.run_command(["--bools true,false,false,true"]).should eq [true, false, false, true] end it "should raise if missing" do - expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.command.call ["--bools="] } - expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.command.call ["--bools"] } - expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.command.call ["--bos"] } - expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.command.call [] of String } + expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.run_command ["--bools="] } + expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.run_command ["--bools"] } + expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.run_command ["--bos"] } + expect_raises Exception, "Required argument 'bools' was not supplied" { ArrayBoolCommand.run_command [] of String } end end end describe "that are optional" do it "should return nil if missing" do - OptionalParamCommand.command.call(["--u=123"]).should be_nil - OptionalParamCommand.command.call(["--u"]).should be_nil - OptionalParamCommand.command.call(["--"]).should be_nil - OptionalParamCommand.command.call(["--foo"]).should be_nil - OptionalParamCommand.command.call(["--g=1.2,1.1"]).should be_nil + OptionalParamCommand.run_command(["--u=123"]).should be_nil + OptionalParamCommand.run_command(["--u"]).should be_nil + OptionalParamCommand.run_command(["--"]).should be_nil + OptionalParamCommand.run_command(["--foo"]).should be_nil + OptionalParamCommand.run_command(["--g=1.2,1.1"]).should be_nil end end end + + describe ".to_s" do + it "should print correctly" do + ToSCommand.to_s.should eq to_s + end + end end diff --git a/spec/cli/commands/array_bool_comand.cr b/spec/cli/commands/array_bool_comand.cr index 7da52cf63..d2a4009a5 100644 --- a/spec/cli/commands/array_bool_comand.cr +++ b/spec/cli/commands/array_bool_comand.cr @@ -1,7 +1,7 @@ require "../cli_spec_helper" struct ArrayBoolCommand < Athena::Cli::Command - self.command_name = "array" + self.name = "aa:array" self.description = "Array of bools" def self.execute(bools : Array(Bool)) : Array(Bool) diff --git a/spec/cli/commands/create_user_command.cr b/spec/cli/commands/create_user_command.cr index a7754c740..308977d7a 100644 --- a/spec/cli/commands/create_user_command.cr +++ b/spec/cli/commands/create_user_command.cr @@ -1,7 +1,7 @@ require "../cli_spec_helper" struct CreateUserCommand < Athena::Cli::Command - self.command_name = "user" + self.name = "user" self.description = "Creates a user with the given id" def self.execute(id : Int32) : Int32 diff --git a/spec/cli/commands/default_value_command.cr b/spec/cli/commands/default_value_command.cr new file mode 100644 index 000000000..26f223872 --- /dev/null +++ b/spec/cli/commands/default_value_command.cr @@ -0,0 +1,11 @@ +require "../cli_spec_helper" + +struct DefaultValueCommand < Athena::Cli::Command + self.name = "params:default" + self.description = "Required param with a default value" + + def self.execute(path : String = "./") : String + path.should be_a(String) + path + end +end diff --git a/spec/cli/commands/multi_param_command.cr b/spec/cli/commands/multi_param_command.cr index 4fecbcccc..0dbad5dae 100644 --- a/spec/cli/commands/multi_param_command.cr +++ b/spec/cli/commands/multi_param_command.cr @@ -1,7 +1,7 @@ require "../cli_spec_helper" struct MultiParamCommand < Athena::Cli::Command - self.command_name = "multi" + self.name = "params:multi" self.description = "Has multiple required params" def self.execute(one : String, two : Int8, three : Float64) : String diff --git a/spec/cli/commands/no_params_command.cr b/spec/cli/commands/no_params_command.cr new file mode 100644 index 000000000..01fb57a94 --- /dev/null +++ b/spec/cli/commands/no_params_command.cr @@ -0,0 +1,10 @@ +require "../cli_spec_helper" + +struct NoParamsCommand < Athena::Cli::Command + self.name = "no_params" + self.description = "No params" + + def self.execute : String + "foo" + end +end diff --git a/spec/cli/commands/optional_param_command.cr b/spec/cli/commands/optional_param_command.cr index 069d0bf83..a0cf9135d 100644 --- a/spec/cli/commands/optional_param_command.cr +++ b/spec/cli/commands/optional_param_command.cr @@ -1,7 +1,7 @@ require "../cli_spec_helper" struct OptionalParamCommand < Athena::Cli::Command - self.command_name = "optional" + self.name = "params:optional" self.description = "optional string" def self.execute(u : String?, g : Array(Float32)?) : Nil diff --git a/spec/cli/commands/to_s_command.cr b/spec/cli/commands/to_s_command.cr new file mode 100644 index 000000000..936a2f6f3 --- /dev/null +++ b/spec/cli/commands/to_s_command.cr @@ -0,0 +1,10 @@ +require "../cli_spec_helper" + +struct ToSCommand < Athena::Cli::Command + self.name = "to_s" + self.description = "Command to test .to_s on" + + def self.execute(optional : String?, required : Bool, path : String = "./") : String + path + end +end diff --git a/spec/cli/compile_spec.cr b/spec/cli/compile_spec.cr new file mode 100644 index 000000000..05d3ddf8a --- /dev/null +++ b/spec/cli/compile_spec.cr @@ -0,0 +1,9 @@ +require "./cli_spec_helper" + +describe Athena::Cli do + describe "with a command that does not have an .execute method" do + it "should not compile" do + assert_error "cli/compiler/no_execute.cr", "NoExecuteCommand must implement a `self.execute` method." + end + end +end diff --git a/spec/cli/compiler/no_execute.cr b/spec/cli/compiler/no_execute.cr new file mode 100644 index 000000000..4f5b91c6d --- /dev/null +++ b/spec/cli/compiler/no_execute.cr @@ -0,0 +1,6 @@ +require "../cli_spec_helper" + +struct NoExecuteCommand < Athena::Cli::Command + self.name = "no:execute" + self.description = "Command with no execute method" +end diff --git a/spec/cli/registry_spec.cr b/spec/cli/registry_spec.cr index 0b9af7712..55992988e 100644 --- a/spec/cli/registry_spec.cr +++ b/spec/cli/registry_spec.cr @@ -1,17 +1,39 @@ require "./cli_spec_helper" +command_list = <<-LIST +Registered Commands: +\taa +\t\taa:array - Array of bools +\tparams +\t\tparams:default - Required param with a default value +\t\tparams:multi - Has multiple required params +\t\tparams:optional - optional string +\tungrouped +\t\tno_params - No params +\t\tto_s - Command to test .to_s on +\t\tuser - Creates a user with the given id\n +LIST + describe Athena::Cli::Registry do - describe "for valid commands" do - it "should be able to find by name" do - command = Athena::Cli::Registry.commands.find { |c| c.command_name == "user" } - command.should_not be_nil - command.not_nil!.description.should eq "Creates a user with the given id" + describe ".find" do + describe "for valid commands" do + it "should be able to find by name" do + command = Athena::Cli::Registry.commands.find { |c| c.name == "user" } + command.should_not be_nil + command.not_nil!.description.should eq "Creates a user with the given id" + end + end + + describe "for an unregistered command" do + it "should raise" do + expect_raises Exception, "No command with the name 'foobar' has been registered" { Athena::Cli::Registry.find "foobar" } + end end end - describe "for an unregistered command" do - it "should raise" do - expect_raises Exception, "No command with the name 'foobar' has been registered" { Athena::Cli::Registry.find "foobar" } + describe ".to_s" do + it "should list the commands" do + Athena::Cli::Registry.to_s.should eq command_list end end end diff --git a/spec/routing/compile_spec.cr b/spec/routing/compile_spec.cr new file mode 100644 index 000000000..ed28183d6 --- /dev/null +++ b/spec/routing/compile_spec.cr @@ -0,0 +1,157 @@ +require "./routing_spec_helper" + +describe Athena::Routing do + describe "With missing" do + describe "action parameters" do + context "query" do + describe "with no action parameters" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/no_action_one_query.cr", "'bar' is defined in CompileController#no_action_one_query path/query parameters but is missing from action arguments." + end + end + + describe "with one action parameter" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_two_query.cr", "'bar' is defined in CompileController#one_action_two_query path/query parameters but is missing from action arguments." + end + end + + describe "with *_id action parameter and non *_id query" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_id_one_query.cr", "'num' is defined in CompileController#one_action_id_one_query path/query parameters but is missing from action arguments." + end + end + end + + context "path parameters" do + describe "with no action parameters" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/no_action_one_path.cr", "'value' is defined in CompileController#no_action_one_path path/query parameters but is missing from action arguments." + end + end + + describe "with one action parameter" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_two_path.cr", "'bar' is defined in CompileController#one_action_two_path path/query parameters but is missing from action arguments." + end + end + + describe "with *_id action parameter and non *_id path" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_id_one_path.cr", "'num' is defined in CompileController#one_action_id_one_path path/query parameters but is missing from action arguments." + end + end + end + + context "path + query parameters" do + describe "with missing query" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_one_query_one_path_query.cr", "'bar' is defined in CompileController#one_action_one_query_one_path_query path/query parameters but is missing from action arguments." + end + end + + describe "with missing path" do + it "should not compile" do + assert_error "routing/compiler/parameters/action/one_action_one_query_one_path_path.cr", "'bar' is defined in CompileController#one_action_one_query_one_path_path path/query parameters but is missing from action arguments." + end + end + end + end + + describe "query parameters" do + describe "with one action parameter" do + it "should not compile" do + assert_error "routing/compiler/parameters/query/one_action_no_query.cr", "'foo' is defined in CompileController#one_action_no_query action arguments but is missing from path/query parameters." + end + end + + describe "with two action parameters" do + it "should not compile" do + assert_error "routing/compiler/parameters/query/two_action_one_query.cr", "'bar' is defined in CompileController#two_action_one_query action arguments but is missing from path/query parameters." + end + end + end + + describe "path parameters" do + describe "with one action parameter" do + it "should not compile" do + assert_error "routing/compiler/parameters/path/one_action_no_path.cr", "'foo' is defined in CompileController#one_action_no_path action arguments but is missing from path/query parameters." + end + end + + describe "with two action parameters" do + it "should not compile" do + assert_error "routing/compiler/parameters/path/two_action_one_path.cr", "'bar' is defined in CompileController#two_action_one_path action arguments but is missing from path/query parameters." + end + end + end + end + + describe "route actions" do + describe "that is a class method action" do + it "should not compile" do + assert_error "routing/compiler/actions/class_method_action.cr", "Routes can only be defined as instance methods. Did you mean 'class_method' within CompileController?" + end + end + + describe "without a return type" do + it "should not compile" do + assert_error "routing/compiler/actions/no_return_type.cr", "Route action return type must be set for 'CompileController#no_return_type'" + end + end + + describe "that has a callback defined as an instance method" do + it "should not compile" do + assert_error "routing/compiler/actions/callback_instance_method.cr", "Controller callbacks can only be defined as class methods. Did you mean 'self.teapot_callback' within CompileController?" + end + end + + describe "that has an exception handler defined as an instance method" do + it "should not compile" do + assert_error "routing/compiler/actions/handle_exception_instance_method.cr", "Exception handlers can only be defined as class methods. Did you mean 'self.handle_exception' within CompileController?" + end + end + end + + describe "param converters" do + describe "with a missing param field" do + it "should not compile" do + assert_error "routing/compiler/converters/no_param.cr", "CompileController.no_param ParamConverter annotation is missing a required field. Must specifiy `param`, `type`, and `converter`." + end + end + + describe "with a missing type field" do + it "should not compile" do + assert_error "routing/compiler/converters/no_type.cr", "CompileController.no_type ParamConverter annotation is missing a required field. Must specifiy `param`, `type`, and `converter`." + end + end + + describe "with a missing converter field" do + it "should not compile" do + assert_error "routing/compiler/converters/no_converter.cr", "CompileController.no_converter ParamConverter annotation is missing a required field. Must specifiy `param`, `type`, and `converter`." + end + end + + context "Exists" do + describe "that does not implement a .find method" do + it "should not compile" do + assert_error "routing/compiler/converters/exists/no_find.cr", "NoFind must implement a `self.find(id)` method to use the Exists converter." + end + end + + describe "that does not have the pk_type field" do + it "should not compile" do + assert_error "routing/compiler/converters/exists/no_pk_type.cr", "CompileController.no_pk_type Exists converter requires a `pk_type` to be defined." + end + end + end + + context "FormData" do + describe "that does not implement a .from_form_data method" do + it "should not compile" do + assert_error "routing/compiler/converters/form_data/no_from_form_data.cr", "NoFormData must implement a `self.from_form_data(form_data : HTTP::Params) : self` method to use the FormData converter." + end + end + end + end +end diff --git a/spec/routing/compiler/actions/callback_instance_method.cr b/spec/routing/compiler/actions/callback_instance_method.cr new file mode 100644 index 000000000..a4f65a2ec --- /dev/null +++ b/spec/routing/compiler/actions/callback_instance_method.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Callback(event: CallbackEvents::OnResponse)] + def teapot_callback(context : HTTP::Server::Context) : Nil + context.response.status_code = 412 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/actions/class_method_action.cr b/spec/routing/compiler/actions/class_method_action.cr new file mode 100644 index 000000000..1566870aa --- /dev/null +++ b/spec/routing/compiler/actions/class_method_action.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/")] + def self.class_method : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/actions/handle_exception_instance_method.cr b/spec/routing/compiler/actions/handle_exception_instance_method.cr new file mode 100644 index 000000000..1ecf0bc53 --- /dev/null +++ b/spec/routing/compiler/actions/handle_exception_instance_method.cr @@ -0,0 +1,9 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + def handle_exception(exception : Exception, ctx : HTTP::Server::Context) + super + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/actions/no_return_type.cr b/spec/routing/compiler/actions/no_return_type.cr new file mode 100644 index 000000000..085b62647 --- /dev/null +++ b/spec/routing/compiler/actions/no_return_type.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/")] + def no_return_type + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/exists/no_find.cr b/spec/routing/compiler/converters/exists/no_find.cr new file mode 100644 index 000000000..500de0695 --- /dev/null +++ b/spec/routing/compiler/converters/exists/no_find.cr @@ -0,0 +1,14 @@ +require "../../../routing_spec_helper" + +class NoFind +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(param: "body", type: NoFind, pk_type: Int64, converter: Exists)] + def no_find(body : NoFind) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/exists/no_pk_type.cr b/spec/routing/compiler/converters/exists/no_pk_type.cr new file mode 100644 index 000000000..6fea8c010 --- /dev/null +++ b/spec/routing/compiler/converters/exists/no_pk_type.cr @@ -0,0 +1,15 @@ +require "../../../routing_spec_helper" + +class NoPk + def self.find(id); end +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(param: "body", type: NoPk, converter: Exists)] + def no_pk_type(body : NoPk) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/form_data/no_from_form_data.cr b/spec/routing/compiler/converters/form_data/no_from_form_data.cr new file mode 100644 index 000000000..e9f3e3c13 --- /dev/null +++ b/spec/routing/compiler/converters/form_data/no_from_form_data.cr @@ -0,0 +1,14 @@ +require "../../../routing_spec_helper" + +class NoFormData +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(param: "body", type: NoFormData, converter: FormData)] + def no_from_data(body : NoFormData) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/no_converter.cr b/spec/routing/compiler/converters/no_converter.cr new file mode 100644 index 000000000..49a58620d --- /dev/null +++ b/spec/routing/compiler/converters/no_converter.cr @@ -0,0 +1,14 @@ +require "../../../routing_spec_helper" + +class NoConverter +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(param: "body", type: NoConverter)] + def no_converter(body : NoConverter) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/no_param.cr b/spec/routing/compiler/converters/no_param.cr new file mode 100644 index 000000000..3a35847fb --- /dev/null +++ b/spec/routing/compiler/converters/no_param.cr @@ -0,0 +1,14 @@ +require "../../../routing_spec_helper" + +class NoParam +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(type: NoParam, converter: Exists)] + def no_param(body : NoParam) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/converters/no_type.cr b/spec/routing/compiler/converters/no_type.cr new file mode 100644 index 000000000..9834b3255 --- /dev/null +++ b/spec/routing/compiler/converters/no_type.cr @@ -0,0 +1,14 @@ +require "../../../routing_spec_helper" + +class NoType +end + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Post(path: "/")] + @[Athena::Routing::ParamConverter(param: "body", converter: Exists)] + def no_type(body : NoType) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/no_action_one_path.cr b/spec/routing/compiler/parameters/action/no_action_one_path.cr new file mode 100644 index 000000000..f18328f8a --- /dev/null +++ b/spec/routing/compiler/parameters/action/no_action_one_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/:value")] + def no_action_one_path : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/no_action_one_query.cr b/spec/routing/compiler/parameters/action/no_action_one_query.cr new file mode 100644 index 000000000..5a71a5ac7 --- /dev/null +++ b/spec/routing/compiler/parameters/action/no_action_one_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/", query: {"bar" => /bar/})] + def no_action_one_query : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_id_one_path.cr b/spec/routing/compiler/parameters/action/one_action_id_one_path.cr new file mode 100644 index 000000000..165669773 --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_id_one_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/:num")] + def one_action_id_one_path(num_id : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_id_one_query.cr b/spec/routing/compiler/parameters/action/one_action_id_one_query.cr new file mode 100644 index 000000000..1346eefb4 --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_id_one_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/", query: {"num" => /\d+/})] + def one_action_id_one_query(num_id : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_one_query_one_path_path.cr b/spec/routing/compiler/parameters/action/one_action_one_query_one_path_path.cr new file mode 100644 index 000000000..c54fc070e --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_one_query_one_path_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/:bar", query: {"foo" => /bar/})] + def one_action_one_query_one_path_path(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_one_query_one_path_query.cr b/spec/routing/compiler/parameters/action/one_action_one_query_one_path_query.cr new file mode 100644 index 000000000..d8543b075 --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_one_query_one_path_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/:foo", query: {"bar" => /bar/})] + def one_action_one_query_one_path_query(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_two_path.cr b/spec/routing/compiler/parameters/action/one_action_two_path.cr new file mode 100644 index 000000000..6003fbd53 --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_two_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/:foo/:bar")] + def one_action_two_path(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/action/one_action_two_query.cr b/spec/routing/compiler/parameters/action/one_action_two_query.cr new file mode 100644 index 000000000..0eb29e592 --- /dev/null +++ b/spec/routing/compiler/parameters/action/one_action_two_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/", query: {"foo" => nil, "bar" => /bar/})] + def one_action_two_query(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/path/one_action_no_path.cr b/spec/routing/compiler/parameters/path/one_action_no_path.cr new file mode 100644 index 000000000..ed0b52feb --- /dev/null +++ b/spec/routing/compiler/parameters/path/one_action_no_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/")] + def one_action_no_path(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/path/two_action_one_path.cr b/spec/routing/compiler/parameters/path/two_action_one_path.cr new file mode 100644 index 000000000..39af6c7dc --- /dev/null +++ b/spec/routing/compiler/parameters/path/two_action_one_path.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/(:foo)")] + def two_action_one_path(foo : String, bar : Bool) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/query/one_action_no_query.cr b/spec/routing/compiler/parameters/query/one_action_no_query.cr new file mode 100644 index 000000000..a86a1ad0e --- /dev/null +++ b/spec/routing/compiler/parameters/query/one_action_no_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/")] + def one_action_no_query(foo : String) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/compiler/parameters/query/two_action_one_query.cr b/spec/routing/compiler/parameters/query/two_action_one_query.cr new file mode 100644 index 000000000..7bfe6c841 --- /dev/null +++ b/spec/routing/compiler/parameters/query/two_action_one_query.cr @@ -0,0 +1,10 @@ +require "../../../routing_spec_helper" + +class CompileController < Athena::Routing::Controller + @[Athena::Routing::Get(path: "int8/", query: {"foo" => nil})] + def two_action_one_query(foo : String, bar : Bool) : Int32 + 123 + end +end + +Athena::Routing.run diff --git a/spec/routing/controllers/user_controller.cr b/spec/routing/controllers/user_controller.cr index dd4f493b4..144713a81 100644 --- a/spec/routing/controllers/user_controller.cr +++ b/spec/routing/controllers/user_controller.cr @@ -96,10 +96,10 @@ class UserController < Athena::Routing::Controller end @[Athena::Routing::Get(path: "users/yaml/:user_id")] - @[Athena::Routing::ParamConverter(param: "user", pk_type: Int64, type: User, converter: Exists)] + @[Athena::Routing::ParamConverter(param: "user_id", pk_type: Int64, type: User, converter: Exists)] @[Athena::Routing::View(renderer: Athena::Routing::Renderers::YAMLRenderer)] - def get_user_yaml(user : User) : User - user + def get_user_yaml(user_id : User) : User + user_id end @[Athena::Routing::Get(path: "users/ecr/:user_id")] diff --git a/spec/routing/routing_spec_helper.cr b/spec/routing/routing_spec_helper.cr index bab56f296..c11e9f10c 100644 --- a/spec/routing/routing_spec_helper.cr +++ b/spec/routing/routing_spec_helper.cr @@ -1,4 +1,4 @@ -require "spec" +require "../spec_helper_methods" require "http/client" require "../../src/routing" require "./controllers/*" @@ -6,6 +6,7 @@ require "./controllers/*" DEFAULT_CONFIG = "athena.yml" CORS_CONFIG = "spec/routing/athena.yml" +# Spawns a server with the given confg file path, runs the block, then stops the server. def do_with_config(path : String = DEFAULT_CONFIG, &block : HTTP::Client -> Nil) : Nil client = HTTP::Client.new "localhost", 8888 spawn { Athena::Routing.run(8888, config_path: path) } diff --git a/spec/spec_helper_methods.cr b/spec/spec_helper_methods.cr new file mode 100644 index 000000000..58c33e0d6 --- /dev/null +++ b/spec/spec_helper_methods.cr @@ -0,0 +1,18 @@ +require "spec" + +# Asserts compile time errors given a *path* to a program and a *message*. +def assert_error(path : String, message : String) : Nil + buffer = IO::Memory.new + result = Process.run("crystal", ["run", "--no-color", "--no-codegen", "spec/" + path], error: buffer) + result.success?.should be_false + buffer.to_s.should contain message + buffer.close +end + +# Runs the the binary with the given *name* and *args*. +def run_binary(name : String = "bin/athena", args : Array(String) = [] of String, &block : String -> Nil) + buffer = IO::Memory.new + Process.run(name, args, error: buffer, output: buffer) + yield buffer.to_s + buffer.close +end diff --git a/src/athena.cr b/src/athena.cr new file mode 100644 index 000000000..e280c506d --- /dev/null +++ b/src/athena.cr @@ -0,0 +1,5 @@ +require "./config/config" +require "./cli" +require "./commands/*" + +Athena::Cli.register_commands diff --git a/src/cli.cr b/src/cli.cr index dec03c4c4..8efc38693 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -8,20 +8,18 @@ require "./cli/registry" # Athena module containing elements for: # * Creating CLI commands. module Athena::Cli - # :nodoc: - private abstract struct Arg; end - - # :nodoc: - private record Argument(T) < Arg, name : String, optional : Bool, type : T.class = T - # Defines an option parser interface for Athena CLI commands. macro register_commands OptionParser.parse! do |parser| parser.banner = "Usage: YOUR_BINARY [arguments]" parser.on("-h", "--help", "Show this help") { puts parser; exit } parser.on("-l", "--list", "List available commands") { puts Athena::Cli::Registry.to_s; exit } + parser.on("-e COMMAND", "--explain COMMAND", "Show more detailed help for a specific command") do |name| + puts Athena::Cli::Registry.find(name).to_s + exit + end parser.on("-c NAME", "--command=NAME", "Run a command with the given name") do |name| - Athena::Cli::Registry.find(name).command.call ARGV + Athena::Cli::Registry.find(name).run_command ARGV exit end end diff --git a/src/cli/command.cr b/src/cli/command.cr index 6bb88c376..8373334eb 100644 --- a/src/cli/command.cr +++ b/src/cli/command.cr @@ -2,9 +2,9 @@ module Athena::Cli # Parent struct for all CLI commands. abstract struct Command # Name of the command. - class_property command_name : String = "" + class_property name : String = "" - # Description of what the command does. + # What the command does. class_property description : String = "" macro inherited @@ -14,23 +14,45 @@ module Athena::Cli \{% for method in @type.class.methods %} \{% if method.name.stringify == "execute" %} - # Executer for the command `MyClass.command.call(args : Array(String))`. - class_getter command : Proc(Array(String), \{{method.return_type}}) = ->(args : Array(String)) do - \{% arg_types = method.args.map(&.restriction) %} - params = Array(Union(\{{arg_types.splat}}) | Nil).new + # Displays more detailed information about `self`. + def self.to_s : String + String.build do |str| + str.puts "Command" + str.puts "\t#{@@name} - #{@@description}" + str.puts "Usage" + str.puts "\t./YOUR_BINARY -c #{@@name} [arguments]" + str.puts "Arguments" + \{% for arg in method.args %} + str.puts "\t#{\{{arg.name.stringify}}} : #{\{{arg.restriction}}}#{\{{arg.default_value.is_a?(Nop) ? "" : " = " + arg.default_value.stringify}}}" + \{% end %} + end + end - \{% for arg in method.args %} - if arg = args.find { |a| a =~ /--\{{arg.name}}=.+/ } - if val = arg.match /--\{{arg.name}}=(.+)/ - params << Athena::Types.convert_type val[1], \{{arg.restriction}} - end - else - raise "Required argument '#{\{{arg.name.stringify}}}' was not supplied." unless (\{{arg.restriction}}).nilable? - params << nil - end - \{% end %} - ->\{{@type}}.execute(\{{arg_types.splat}}).call *Tuple(\{{arg_types.splat}}).from params - end + # :nodoc: + def self.run_command(args : Array(String)) : \{{method.return_type}} + \{% if method.args.empty? %} + ->{ \{{@type}}.execute }.call + \{% else %} + \{% arg_types = method.args.map(&.restriction) %} + params = Array(Union(\{{arg_types.splat}}) | Nil).new + + \{% for arg in method.args %} + if arg = args.find { |a| a =~ /--\{{arg.name}}[=\s].+/ } + if val = arg.match /--\{{arg.name}}[=\s](.+)/ + params << Athena::Types.convert_type val[1], \{{arg.restriction}} + end + else + \{% if arg.default_value.is_a? Nop %} + raise "Required argument '#{\{{arg.name.stringify}}}' was not supplied." unless (\{{arg.restriction}}).nilable? + params << nil + \{% else %} + params << \{{arg.default_value}} + \{% end %} + end + \{% end %} + ->\{{@type}}.execute(\{{arg_types.splat}}).call *Tuple(\{{arg_types.splat}}).from params + \{% end %} + end \{% end %} \{% end %} \{% end %} diff --git a/src/cli/registry.cr b/src/cli/registry.cr index 5181404ee..b52bd86d9 100644 --- a/src/cli/registry.cr +++ b/src/cli/registry.cr @@ -3,15 +3,23 @@ module Athena::Cli struct Registry macro finished # Array of available commands. Auto registered at compile time. - class_getter commands : Array(Athena::Cli::Command.class) = {{Athena::Cli::Command.subclasses}}{% if Athena::Cli::Command.subclasses.size > 0 %} of Athena::Cli::Command.class {% end %} + class_getter commands : Array(Athena::Cli::Command.class) = {% if Athena::Cli::Command.subclasses.size > 0 %}{{Athena::Cli::Command.subclasses}} {% else %} [] {% end %} of Athena::Cli::Command.class end # Displays the available commands. def self.to_s : String String.build do |str| - str.puts "Registered commands:" - @@commands.each do |command| - str.puts "\t#{command.command_name} - #{command.description}" + groups = @@commands.group_by { |g| g.name.count(':').zero? ? "" : g.name.split(':').first } + group_names = groups.keys.reject(&.==("")).sort + group_names << "" if groups.has_key? "" + + str.puts "Registered Commands:" + group_names.each do |group| + str.puts "\t#{group.empty? ? "ungrouped" : group}" + groups[group].sort_by(&.name).each do |c| + next if c.name.blank? || c.description.blank? + str.puts "\t\t#{c.name} - #{c.description}" + end end end end @@ -20,7 +28,7 @@ module Athena::Cli # # Raises if no command has that name. def self.find(name : String) : Athena::Cli::Command.class - command_class = @@commands.find { |c| c.command_name == name } + command_class = @@commands.find { |c| c.name == name } raise "No command with the name '#{name}' has been registered" if command_class.nil? command_class end diff --git a/src/commands/generate_config_file_command.cr b/src/commands/generate_config_file_command.cr new file mode 100644 index 000000000..36c48b5b3 --- /dev/null +++ b/src/commands/generate_config_file_command.cr @@ -0,0 +1,24 @@ +require "../cli" + +# Commands that come with the `athena` executable for Athena specific tasks. +module Athena::Commands + # Generates the Athena config file. + # ## Usage + # `./bin/athena -c athena:generate:config_file --path /path/to/destination --override true` + # ## Arguments + # * override : Bool - Whether to override the existing config file. + # * path : String - The path that the config file should be generated at. + struct GenerateConfigFileCommand < Athena::Cli::Command + self.name = "athena:generate:config_file" + self.description = "Generates the default config file for Athena" + + def self.execute(override : Bool = false, path : String = "./athena.yml") : Nil + if !File.exists?(path) || override + File.open path, "w" do |file| + file.print "# Config file for Athena.\n" + file.print Athena::Config::Config.new.to_yaml + end + end + end + end +end diff --git a/src/config/init.cr b/src/config/init.cr deleted file mode 100644 index 4b780c47e..000000000 --- a/src/config/init.cr +++ /dev/null @@ -1,10 +0,0 @@ -require "./config" - -# This file gets executed on install to initialize Athena. -# Currently just creating the `athena.yml` config file on install if it does not exist. -unless File.exists? "../../athena.yml" - File.open "../../athena.yml", "w" do |file| - file.print "# Config file for Athena.\n" - file.print Athena::Config::Config.new.to_yaml - end -end diff --git a/src/routing.cr b/src/routing.cr index 8b516a279..f41a9ba4a 100644 --- a/src/routing.cr +++ b/src/routing.cr @@ -43,6 +43,7 @@ module Athena::Routing # Defines a GET endpoint. # ## Fields # * path : `String` - The path for the endpoint. + # * cors : `String|Bool|Nil` - The `cors_group` to use for this specific action, or `false` to disable CORS. # # ## Example # ``` @@ -53,6 +54,7 @@ module Athena::Routing # Defines a POST endpoint. # ## Fields # * path : `String` - The path for the endpoint. + # * cors : `String|Bool|Nil` - The `cors_group` to use for this specific action, or `false` to disable CORS. # # ## Example # ``` @@ -63,6 +65,7 @@ module Athena::Routing # Defines a PUT endpoint. # ## Fields # * path : `String` - The path for the endpoint. + # * cors : `String|Bool|Nil` - The `cors_group` to use for this specific action, or `false` to disable CORS. # # ## Example # ``` @@ -73,6 +76,7 @@ module Athena::Routing # Defines a DELETE endpoint. # ## Fields # * path : `String` - The path for the endpoint. + # * cors : `String|Bool|Nil` - The `cors_group` to use for this specific action, or `false` to disable CORS. # # ## Example # ``` @@ -120,11 +124,12 @@ module Athena::Routing # Defines options that affect the whole controller. # ## Fields # * prefix : String - Apply a prefix to all actions within `self`. + # * cors : `String|Bool|Nil` - The `cors_group` to use for all actions within this controller, or `false` to disable CORS for all actions. # # ## Example # ``` # @[Athena::Routing::ControllerOptions(prefix: "calendar")] - # struct CalendarController < Athena::Routing::Controller + # class CalendarController < Athena::Routing::Controller # # The rotue of this action would be `GET /calendar/events` # @[Athena::Routing::Get(path: "events")] # def self.events : String @@ -143,7 +148,7 @@ module Athena::Routing OnResponse end - # Parent struct for all controllers. + # Parent class for all controllers. abstract class Controller # Exits the request with the given *status_code* and *body*. # @@ -158,6 +163,7 @@ module Athena::Routing return end + # Initializes a controller with the current `HTTP::Server::Context`. def initialize(@ctx : HTTP::Server::Context); end # Returns the request object for the current request @@ -198,7 +204,13 @@ module Athena::Routing # :nodoc: private abstract struct CallbackBase; end - private record RouteDefinition, path : String, cors_group : String | Bool | Nil = nil + # :nodoc: + private record RouteDefinition, + # The path that this action is responsible for. + path : String, + + # The `cors_group` to use for this action. + cors_group : String | Bool | Nil = nil # :nodoc: private record RouteAction(A, R, C) < Action, @@ -257,7 +269,7 @@ module Athena::Routing end end - # Starts the HTTP server with the given *port*, *binding*, *ssl*, and *handlers*. + # Starts the HTTP server with the given *port*, *binding*, *ssl*, *handlers*, and *path*. def self.run(port : Int32 = 8888, binding : String = "0.0.0.0", ssl : OpenSSL::SSL::Context::Server? | Bool? = nil, handlers : Array(HTTP::Handler) = [] of HTTP::Handler, config_path : String = "athena.yml") config : Athena::Config::Config = Athena::Config::Config.from_yaml File.read config_path diff --git a/src/routing/exceptions.cr b/src/routing/exceptions.cr index ad6b02728..98aad5de3 100644 --- a/src/routing/exceptions.cr +++ b/src/routing/exceptions.cr @@ -56,7 +56,7 @@ module Athena::Routing::Exceptions {% for code, exception in COMMON_EXCEPTIONS %} {% class_name = exception.gsub(/[\s\']/, "") %} - # Raises a {{exception}} exception with code {{code}} + # Raises a {{exception}} exception with code {{code}}. class {{class_name.id}}Exception < Athena::Routing::Exceptions::AthenaException def initialize(message : String = {{exception}}) super {{code.id}}, message diff --git a/src/routing/ext/granite.cr b/src/routing/ext/granite.cr index 84745ee97..bca6f5dfd 100644 --- a/src/routing/ext/granite.cr +++ b/src/routing/ext/granite.cr @@ -41,7 +41,7 @@ class Athena::Routing::Controller def self.handle_exception(execption : Exception, ctx : HTTP::Server::Context) if msg = execption.message if parts = msg.match(/.*\#(.*) cannot be nil/) - halt ctx, 400, %({"code": 400, "message": "'#{parts[1]}' cannot be null"}) + throw 400, %({"code": 400, "message": "'#{parts[1]}' cannot be null"}) end end diff --git a/src/routing/handlers/cors_handler.cr b/src/routing/handlers/cors_handler.cr index 77d167d69..a6c1b90e2 100644 --- a/src/routing/handlers/cors_handler.cr +++ b/src/routing/handlers/cors_handler.cr @@ -1,7 +1,7 @@ require "./handler" module Athena::Routing::Handlers - # Handles routing and param conversion on each request. + # Handles CORS for the given action. class CorsHandler < Athena::Routing::Handlers::Handler # ameba:disable Metrics/CyclomaticComplexity def handle(ctx : HTTP::Server::Context, action : Action, config : Athena::Config::Config) diff --git a/src/routing/handlers/handler.cr b/src/routing/handlers/handler.cr index 330079595..7b5f13e40 100644 --- a/src/routing/handlers/handler.cr +++ b/src/routing/handlers/handler.cr @@ -12,6 +12,7 @@ end # Handlers that will be executed within each request's life-cycle. module Athena::Routing::Handlers + # Base class for request handlers. Can be inherited to implement custom logic for each request. abstract class Handler include HTTP::Handler diff --git a/src/routing/handlers/route_handler.cr b/src/routing/handlers/route_handler.cr index 90c985b1f..50229b243 100644 --- a/src/routing/handlers/route_handler.cr +++ b/src/routing/handlers/route_handler.cr @@ -5,6 +5,7 @@ module Athena::Routing::Handlers class RouteHandler < Athena::Routing::Handlers::Handler @routes : Amber::Router::RouteSet(Action) = Amber::Router::RouteSet(Action).new + # :nodoc: def handle(ctx : HTTP::Server::Context, action : Action, config : Athena::Config::Config) : Nil; end # ameba:disable Metrics/CyclomaticComplexity @@ -66,7 +67,7 @@ module Athena::Routing::Handlers # Build out the routes {% for m in methods %} - {% raise "Route action return type must be set for #{c.name}.#{m.name}" if m.return_type.stringify.empty? %} + {% raise "Route action return type must be set for '#{c.name}##{m.name}'" if m.return_type.stringify.empty? %} {% view_ann = m.annotation(View) %} {% param_converter = m.annotation(ParamConverter) %} @@ -77,9 +78,9 @@ module Athena::Routing::Handlers {% raise "#{param_converter[:type]} must implement a `self.find(id)` method to use the Exists converter." unless param_converter[:type].resolve.class.has_method?("find") %} {% raise "#{c.name}.#{m.name} #{param_converter[:converter]} converter requires a `pk_type` to be defined." unless param_converter[:pk_type] %} {% elsif param_converter[:converter].stringify == "RequestBody" %} - {% raise "#{param_converter[:type]} must `include CrSerializer` or implement a `self.from_json(body : String) : self` method to use the RequestBody converter." unless param_converter[:type].resolve.class.has_method?("from_json") %} + {% raise "#{param_converter[:type]} must `include CrSerializer` to use the RequestBody converter." unless param_converter[:type].resolve.class.has_method?("from_json") %} {% elsif param_converter[:converter].stringify == "FormData" %} - {% raise "#{param_converter[:type]} implement a `self.from_form_data(form_data : HTTP::Params) : self` method to use the FormData converter." unless param_converter[:type].resolve.class.has_method?("from_form_data") %} + {% raise "#{param_converter[:type]} must implement a `self.from_form_data(form_data : HTTP::Params) : self` method to use the FormData converter." unless param_converter[:type].resolve.class.has_method?("from_form_data") %} {% end %} {% elsif param_converter %} {% raise "#{c.name}.#{m.name} ParamConverter annotation is missing a required field. Must specifiy `param`, `type`, and `converter`." %} @@ -99,6 +100,7 @@ module Athena::Routing::Handlers {% route_def = d %} {% end %} + # Set and normalize the prefix if one exists {% prefix = class_ann && class_ann[:prefix] ? parent_prefix + (class_ann[:prefix].starts_with?('/') ? class_ann[:prefix] : "/" + class_ann[:prefix]) : parent_prefix %} # Normalize the path @@ -117,7 +119,7 @@ module Athena::Routing::Handlers {% action_params = m.args.map(&.name.stringify) %} {% for p in (query_params.keys + route_params + ({"POST", "PUT"}.includes?(method) ? ["body"] : [] of String)) %} - {% raise "'#{p.id}' is defined in #{c.name}.#{m.name} path/query parameters but is missing from action arguments." if !(action_params.includes?(p.gsub(/_id$/, "")) || action_params.includes?(p)) %} + {% raise "'#{p.id}' is defined in #{c.name}##{m.name} path/query parameters but is missing from action arguments." if !(action_params.includes?(p.gsub(/_id$/, "")) || action_params.includes?(p)) %} {% end %} {% params = [] of Param %} @@ -151,8 +153,9 @@ module Athena::Routing::Handlers {% found = true %} {% end %} {% end %} - {% raise "'#{arg.name}' is defined in #{c.name}.#{m.name} action arguments but is missing from path/query parameters." unless found %} + {% raise "'#{arg.name}' is defined in #{c.name}##{m.name} action arguments but is missing from path/query parameters." unless found %} {% end %} + {% constraints = route_def[:constraints] %} {% arg_types = m.args.map(&.restriction) %} @@ -199,6 +202,7 @@ module Athena::Routing::Handlers {% end %} end + # Entrypoint of a request. def call(ctx : HTTP::Server::Context) # If this is a OPTIONS request change the method to the requested method to access the actual action that will be invoked. method : String = if ctx.request.method == "OPTIONS"